Compare commits

..
39 Commits
Author SHA1 Message Date
Linus Rath 0913dbd3e4 chore: update version to 1.5.1 2026-04-25 16:34:45 +02:00
Linus Rath 29197ea355 feat: replace folder prompt() calls with proper modal dialog 2026-04-25 16:30:18 +02:00
Linus Rath 4788e8a91a feat: add right-click context menu to mail folders sidebar 2026-04-25 16:26:05 +02:00
Linus Rath b80678b00f feat: add 'Today' button to desktop calendar toolbar 2026-04-25 03:11:16 +02:00
Linus Rath 4f7c9c332b feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs 2026-04-25 02:53:12 +02:00
Linus Rath da103ff06f feat: implement OAuth auto-setup functionality for Stalwart integration 2026-04-25 01:37:18 +02:00
Linus Rath 2111c77870 fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:13:17 +02:00
Linus Rath e5083ec1df fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:12:33 +02:00
Linus Rath df8d04e233 fix: restore broken viewer toolbar actions and improve mobile menu #220 2026-04-24 19:31:38 +02:00
Linus RathandGitHub 9a11a18a44 Update README.md 2026-04-24 17:02:00 +02:00
Christian Lepthien ce9f7af330 fix: improve corean flag 2026-04-24 14:56:58 +02:00
Linus Rath 081e8a0310 feat: add setting to show avatars in junk folder, disabled by default 2026-04-23 18:23:48 +02:00
Linus Rath 6c3529b368 fix: stop flicker on background folder refresh 2026-04-23 18:06:40 +02:00
Linus Rath 077a4f03a7 fix: preserve search/filter on batch move and archive 2026-04-23 18:01:21 +02:00
Linus Rath b04dfaf252 fix: preserve search/filter when moving emails via drag-drop 2026-04-23 16:37:17 +02:00
Linus Rath ec5593f567 Merge branch 'dev' into main 2026-04-23 00:10:22 +02:00
Linus Rath c30c38a7af chore: update version to 1.5.0 2026-04-23 00:07:34 +02:00
Linus Rath 7494fc1776 chore: update version to 1.5.0 2026-04-22 23:49:44 +02:00
Linus Rath 16fe92d2e9 chore: update version to 1.5.0 2026-04-22 23:31:32 +02:00
Linus Rath 3544e6a9e7 chore: bump dependencies to latest compatible versions 2026-04-22 23:03:05 +02:00
Linus Rath dd1f3e11e6 fix: retry mailbox fetch on first login to handle lazy provisioning #217 2026-04-22 22:45:49 +02:00
Linus Rath 6503482b55 feat: support resizing events from the top edge 2026-04-22 21:14:28 +02:00
Linus Rath cab57f6cd7 feat: add timezone-aware formatting for event start times and update utcEnd on duration change 2026-04-22 21:05:41 +02:00
Linus Rath 27f4fbdce4 feat: optimize layout of overlapping events in calendar 2026-04-22 20:53:04 +02:00
Linus Rath 39a228b20e feat: streamline mobile email viewer header layout 2026-04-22 20:50:12 +02:00
Linus Rath 4b069808d6 feat: add isMobile prop to calendar views and time grid interactions 2026-04-22 20:37:00 +02:00
Linus Rath 468851ff25 feat: show full folder path in move/drop toast 2026-04-22 00:14:49 +02:00
Linus Rath b7374570c8 feat: implement bulk email moving functionality in email store 2026-04-22 00:10:40 +02:00
Linus Rath 3c9fa5dc25 feat: implement batch archiving of emails 2026-04-22 00:05:38 +02:00
Linus Rath 3ade1c6473 fix: update mailbox retrieval logic in archive handling to use fresh state 2026-04-22 00:00:43 +02:00
Linus Rath 1810a474a2 fix: enhance error message for mailbox creation failure 2026-04-22 00:00:01 +02:00
Linus Rath 40982bc37b fix: align hover styles and selection-toggle target with focused item 2026-04-21 23:50:55 +02:00
Linus Rath 76f6149841 feat: add setting to toggle alphabetical grouping in contacts list 2026-04-21 23:22:18 +02:00
Linus Rath 7d6a3c8c76 feat: group contacts by first letter with sticky section headers 2026-04-21 23:15:13 +02:00
Linus Rath 361ad49f5f fix: read matchMedia synchronously on client to prevent layout flicker 2026-04-21 23:02:21 +02:00
Linus Rath f9aa5cbaee fix: skip checkAuth on route change when already authenticated 2026-04-21 22:54:02 +02:00
Linus Rath 3f36045990 feat: reorganize settings into 6 groups with clearer tabs 2026-04-21 22:22:02 +02:00
Linus Rath 9a44babcf1 feat: add collapsible details to calendar invitation banner and update sanitization config for iframe rendering 2026-04-21 21:21:42 +02:00
Linus Rath f032758303 feat: revamp contact detail, filters, and add photo/print/duplicate 2026-04-21 21:16:54 +02:00
129 changed files with 7097 additions and 94217 deletions
+63
View File
@@ -1,5 +1,68 @@
# Changelog # Changelog
## 1.5.1 (2026-04-25)
### Features
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
- **Mail**: Right-click context menu on the folders sidebar
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
- **Junk**: Setting to show avatars in the Junk folder (off by default)
### Fixes
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
- **Folders**: Stop flicker on background folder refresh
- **Email**: Preserve search/filter on batch move and archive
- **Email**: Preserve search/filter when moving emails via drag-drop
- **i18n**: Improve Korean flag
## 1.5.0 (2026-04-22)
### Breaking Changes
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
### Features
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
- **Admin**: Add API Keys management and IP allowlist for App Passwords
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
- **Contacts**: Add contact activity component showing recent emails and upcoming events
- **Contacts**: Add right-click context menu
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
- **Calendar**: Support resizing events from the top edge
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
- **Calendar**: Optimize layout of overlapping events
- **Calendar**: Add collapsible details to calendar invitation banner
- **Email**: Implement batch archiving and bulk moving of emails
- **Email**: Show full folder path in move/drop toast
- **Settings**: Reorganize settings into 6 groups with clearer tabs
- **Navigation**: Add account-addition button to the navigation rail
- **Mobile**: Streamline email viewer header layout
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
### Fixes
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
- **Mailbox**: Improve error message on mailbox creation failure
- **Auth**: Skip `checkAuth` on route change when already authenticated
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
- **UI**: Align hover styles and selection-toggle target with focused item
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
### Refactor
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
### Chore
- **i18n**: Add missing translation keys
- **Deps**: Bump dependencies to latest compatible versions
## 1.4.14 (2026-04-16) ## 1.4.14 (2026-04-16)
Thank you for your donations: Thank you for your donations:
+124
View File
@@ -0,0 +1,124 @@
# Features
## Mail
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts
- Draft auto-save with identity preservation
- Attachment upload, download, and inline preview; forgotten-attachment warning
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- Plain-text composer mode and Reply-To support
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer
- Browser history sync for back/forward navigation
## Calendar
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
- Recurring events with scoped edit/delete (this / this and following / all)
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
- Inline calendar invitations in the email viewer auto-detect `.ics`, RSVP, import
- iCalendar import with preview, bulk create, and UID deduplication
- iCal / webcal subscriptions with editing and batch import
- Auto-generated birthday calendar from contacts
- Virtual locations (video conference URLs) as first-class event fields
- Task management with due dates, priority, and completion status
- Shared calendars with CalDAV discovery and multi-account home resolution
- Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push
## Contacts
- JMAP sync (RFC 9553 / 9610) with local fallback
- Multiple address books with drag-and-drop between books
- Contact groups with member management
- vCard import/export (RFC 6350) with duplicate detection
- Trusted senders stored in a dedicated JMAP address book
- Autocomplete in the composer (To / Cc / Bcc)
## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
- Preserves rules authored in other clients
- Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
## Files
- JMAP FileNode browser (Stalwart native cloud storage)
- Streamed WebDAV PUT upload and folder upload with progress tracking
- Dynamic upload limits based on server configuration
- Grid and list views with sorting by name, size, or date
- Previews for images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
## Security & Privacy
- External content blocked by default, with a trusted senders list
- HTML sanitization via DOMPurify
- S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
- SPF / DKIM / DMARC status indicators
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- TOTP two-factor authentication
- Account security panel for password and 2FA management via the Stalwart admin API
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
- Plugin hardening with dangerous-pattern detection and admin approval
- Newsletter unsubscribe (RFC 2369)
## Interface
- Three-pane layout with resizable columns
- Dark and light themes with intelligent email color transformation
- Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation
- Drag-and-drop email organization and tag assignment
- Interactive guided tour for new users
- Right-click context menus, toast notifications with undo
- Customizable toolbar position, favicon, and login branding
- Pinnable sidebar apps with drag-and-drop reordering
- Encrypted settings sync across devices
- Storage quota display
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
## Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
## Identity & Multi-Account
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
- Account switcher with connection status and default account selection
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
- Shared folders across accounts
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
## Admin & Extensibility
- Stalwart admin dashboard with dedicated policy sections
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- Bundled plugins including Jitsi Meet calendar integration
## Operations
- Progressive Web App with service worker, install prompt, and dynamic manifest
- Automatic update check with server-side logging of new releases
- Structured logging (`text` or `json`) with category-based levels
- Release (`main`) and development (`dev`) Docker images on GHCR
- Demo mode with fixture data no mail server required
+69 -248
View File
@@ -8,12 +8,11 @@
# Bulwark Webmail # Bulwark Webmail
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/> A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.14-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.5.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
## Screenshots ## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<table> <table>
<tr> <tr>
<td width="50%"> <td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** - Three-pane layout with sidebar, email list, and viewer
</td>
<td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** - Month, week, day, and agenda views with event management
</td>
</tr> </tr>
<tr> <tr>
<td width="50%"> <td><sub><b>Calendar</b> month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
<td><sub><b>Contacts</b> multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
<img src="screenshots/contacts.png" width="100%" alt="Contacts"> </tr>
<tr>
**Contacts** - Contact management with groups and vCard support <td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</td> </tr>
<td width="50%"> <tr>
<td><sub><b>Themes</b> bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<img src="screenshots/files.png" width="100%" alt="File browser"> <td><sub><b>Plugins</b> extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
**Files** - Cloud file browser with upload, preview, and folder navigation <tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
</td> <td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr> </tr>
</table> </table>
<details> ## Overview
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode"> Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
**Light mode** - Full theme support with intelligent color transformation - **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
</td> Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
<td width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings"> Full feature list: **[FEATURES.md](FEATURES.md)**.
**Settings** - Appearance, identities, filters, templates, and more
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/login.png" width="100%" alt="Login page">
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
</td>
<td width="50%">
</td>
</tr>
</table>
</details>
## Features
### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
- **Unified mailbox** - view emails across all accounts in a single list
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** - archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer
- **Answered/forwarded status icons** in email list and thread views
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
- **Star/unstar** with configurable mark-as-read delay
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
- **Sender avatars** - favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction
- **Plain text composer mode** and auto-select reply identity
- **Reply-to addresses** support in the composer
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
- **message/rfc822 unwrapping** for embedded messages
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
- **Tag counts** - unread and total counts displayed in sidebar
- **Browser history sync** - back/forward navigation mirrors mail view state
### Calendar
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
- **Event hover preview** popover with configurable details
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview, bulk create, and UID deduplication
- **iCal / webcal subscriptions** with editing and batch import
- **Birthday calendar** - auto-generated from contacts
- **Virtual locations** - video conference URLs as first-class event fields
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
- **Shared calendars** with visual grouping in the sidebar
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
- **Week numbers** in mini-calendar sidebar
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push
### Contacts
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
- **Collapsible sidebar** with address book grouping and bulk operations
- **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection
- **Trusted senders** stored in a dedicated JMAP address book
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** - multi-select, delete, group add, export
### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
- **External rule preservation** - rules authored in other clients are displayed and preserved
- **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Files
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
- **Dynamic upload limits** - respects the server-configured maximum upload size
- **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** - cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access
- **Bulk operations** - multi-select, delete, move, download
### Security & Privacy
- **External content blocked** by default - trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
- **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
- **TOTP two-factor authentication**
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
- **Newsletter unsubscribe** (RFC 2369)
### Interface
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation
- **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** - full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Interactive guided tour** - onboarding walkthrough for new users
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
- **Settings sync** - preferences synchronized with the server (encrypted)
- **Storage quota** display
- **Version badge** in settings
- **Focused mode** with proper viewport bounds
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
### Identity Management
- **Multiple sender identities** with per-identity signatures
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list
### Multi-Account
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
- **Account switcher** with connection status, default account selection, and per-account logout
- **Per-account settings** - encrypted settings storage with server-side sync
- **Shared folders** across accounts
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
### Admin & Extensibility
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- **Bundled plugins** - Jitsi Meet calendar integration
### Operations
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
- **Automatic update check** - server logs when a newer release is available
- **Logging categories** with `text` or `json` formats for log aggregation
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
--- ---
## Quick Start ## Quick Start
### Docker (recommended) ### Docker
```bash ```bash
docker run -d -p 3000:3000 \ docker run -d -p 3000:3000 \
@@ -255,7 +82,7 @@ Or with Docker Compose:
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL # Edit .env.local set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail cd webmail
npm install npm install
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL # Edit .env.local set JMAP_SERVER_URL
npm run build && npm start npm run build && npm start
``` ```
### Development ### Development
```bash ```bash
npm run dev # Start dev server (mock JMAP server included) npm run dev # Dev server with a mock JMAP server
npm run typecheck # Type checking npm run typecheck
npm run lint # Linting npm run lint
``` ```
## Configuration ## Configuration
Edit `.env.local`: All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env ```env
# Required # Required
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail APP_NAME=My Webmail
``` ```
All variables are **runtime** - Docker deployments can be configured without rebuilding.
<details> <details>
<summary>Server Listen Address</summary> <summary>Server listen address</summary>
```env ```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6 HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000 # Default listen port PORT=3000
``` ```
</details> </details>
<details> <details>
<summary>OAuth2/OIDC (SSO)</summary> <summary>OAuth2 / OIDC</summary>
```env ```env
OAUTH_ENABLED=true OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) OAUTH_ISSUER_URL= # optional, for external IdPs
``` ```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
@@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details> </details>
<details> <details>
<summary>Remember Me & Settings Sync</summary> <summary>Session & settings sync</summary>
```env ```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker) SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
``` ```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry). Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details> </details>
<details> <details>
<summary>Custom JMAP Endpoint</summary> <summary>Custom JMAP endpoint</summary>
```env ```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login ALLOW_CUSTOM_JMAP_ENDPOINT=true
``` ```
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin. Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
</details> </details>
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
```env ```env
APP_NAME=My Webmail APP_NAME=My Webmail
APP_SHORT_NAME=Webmail # Home-screen label on mobile APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail # Shown during PWA install APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6 # Browser chrome color PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg APP_LOGO_DARK_URL=/branding/logo-dark.svg
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
</details> </details>
<details> <details>
<summary>Extension Directory</summary> <summary>Extension directory</summary>
```env ```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
@@ -382,13 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
</details> </details>
<details> <details>
<summary>Stalwart Integration & Logging</summary> <summary>Stalwart integration & logging</summary>
```env ```env
STALWART_FEATURES=true # Password change, sieve filters, etc. STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json" LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug" LOG_LEVEL=info # error | warn | info | debug
``` ```
</details> </details>
@@ -423,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
## Why Stalwart? ## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies. [Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
## Contributing ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. See [CONTRIBUTING.md](CONTRIBUTING.md).
## License ## License
[GNU AGPL v3](LICENSE) [GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
## Acknowledgments ## Acknowledgments
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon. Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
+1 -1
View File
@@ -1 +1 @@
1.4.14 1.5.1
+8 -1
View File
@@ -126,8 +126,15 @@ export default function CalendarPage() {
} }
}, [events, detailEvent]); }, [events, detailEvent]);
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
+39 -20
View File
@@ -98,13 +98,20 @@ export default function ContactsPage() {
// Panel resize state - contact list // Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => { const [listWidth, setListWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; } try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
}); });
const [isListResizing, setIsListResizing] = useState(false); const [isListResizing, setIsListResizing] = useState(false);
const listDragStartWidth = useRef(320); const listDragStartWidth = useRef(384);
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
@@ -172,21 +179,6 @@ export default function ContactsPage() {
return getGroupMembers(activeCategory.groupId); return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]); }, [activeCategory, individuals, getGroupMembers]);
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all");
}
if ("keyword" in activeCategory) {
return activeCategory.keyword;
}
const group = contacts.find(c => c.id === activeCategory.groupId);
return group ? getContactDisplayName(group) : t("tabs.all");
}, [activeCategory, contacts, addressBooks, t]);
const handleSelectCategory = useCallback((category: ContactCategory) => { const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category); setActiveCategory(category);
clearSelection(); clearSelection();
@@ -306,6 +298,24 @@ export default function ContactsPage() {
setView("bulk-add-to-group"); setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]); }, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => { const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) { if (supportsSync && client) {
await createContact(client, data); await createContact(client, data);
@@ -607,6 +617,16 @@ export default function ContactsPage() {
contact={selectedContact} contact={selectedContact}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
: undefined
}
onDuplicate={
selectedContact
? () => void handleDuplicateContact(selectedContact)
: undefined
}
isMobile={isMobile} isMobile={isMobile}
/> />
); );
@@ -702,7 +722,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact} onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew} onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1" className="flex-1"
selectedContactIds={selectedContactIds} selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection} onToggleSelection={toggleContactSelection}
@@ -726,7 +745,7 @@ export default function ContactsPage() {
setIsListResizing(false); setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth)); localStorage.setItem("contacts-list-width", String(listWidth));
}} }}
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }} onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
/> />
)} )}
</> </>
+8 -1
View File
@@ -105,8 +105,15 @@ export default function FilesPage() {
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
+259 -32
View File
@@ -8,12 +8,13 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer"; import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
@@ -23,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
@@ -35,6 +37,7 @@ import {
ComposerErrorFallback, ComposerErrorFallback,
} from "@/components/error"; } from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context"; import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
@@ -67,6 +70,7 @@ export default function Home() {
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0); const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false);
@@ -162,6 +166,11 @@ export default function Home() {
fetchUnifiedEmails: fetchUnifiedEmailsAction, fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts, refreshUnifiedCounts,
exitUnifiedView, exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
createMailbox,
renameMailbox,
deleteMailbox,
} = useEmailStore(); } = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -455,8 +464,15 @@ export default function Home() {
document.title = title; document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
@@ -493,7 +509,10 @@ export default function Home() {
// Load mailboxes and emails when authenticated (only if not already loaded) // Load mailboxes and emails when authenticated (only if not already loaded)
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) { if (isAuthenticated && client && mailboxes.length === 0) {
const loadData = async () => { let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try { try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([ await Promise.all([
@@ -505,6 +524,15 @@ export default function Home() {
const state = useEmailStore.getState(); const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox; const selectedMailboxId = state.selectedMailbox;
// On first login the server may still be provisioning mailboxes.
// Retry a few times with back-off before giving up.
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
// Fetch emails for the selected mailbox // Fetch emails for the selected mailbox
if (selectedMailboxId) { if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId); await fetchEmails(client, selectedMailboxId);
@@ -538,6 +566,12 @@ export default function Home() {
} }
}; };
loadData(); loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
};
} }
// Cleanup push notifications on unmount // Cleanup push notifications on unmount
@@ -813,8 +847,11 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return; if (!client || !emailToArchive) return;
// Find archive mailbox // Read fresh mailboxes from the store batch archive calls this in a loop,
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); // and each iteration needs to see folders created by prior iterations.
const currentMailboxes = useEmailStore.getState().mailboxes;
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return; if (!archiveMailbox) return;
const { archiveMode } = useSettingsStore.getState(); const { archiveMode } = useSettingsStore.getState();
@@ -823,14 +860,12 @@ export default function Home() {
if (archiveMode === 'single') { if (archiveMode === 'single') {
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
} else { } else {
// Determine year/month from the email's received date
const emailDate = new Date(emailToArchive.receivedAt); const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString(); const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0'); const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
const archiveId = archiveMailbox.originalId || archiveMailbox.id; const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Find or create year subfolder under archive let yearMailbox = currentMailboxes.find(
let yearMailbox = mailboxes.find(
m => m.name === year && m.parentId === archiveId m => m.name === year && m.parentId === archiveId
); );
if (!yearMailbox) { if (!yearMailbox) {
@@ -841,9 +876,9 @@ export default function Home() {
if (archiveMode === 'year') { if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else { } else {
// archiveMode === 'month' - find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id; const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = mailboxes.find( const afterYear = useEmailStore.getState().mailboxes;
let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId m => m.name === month && m.parentId === yearId
); );
if (!monthMailbox) { if (!monthMailbox) {
@@ -1071,6 +1106,197 @@ export default function Home() {
} }
}; };
const tCtxMenu = t;
const handleMarkFolderRead = async (mailboxId: string) => {
if (!client) return;
try {
const count = await markMailboxAsRead(client, mailboxId);
await fetchMailboxes(client);
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
if (count > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkFolderTreeRead = async (mailboxId: string) => {
if (!client) return;
const collectIds = (rootId: string): string[] => {
const ids: string[] = [rootId];
const stack = [rootId];
while (stack.length > 0) {
const current = stack.pop()!;
for (const mb of mailboxes) {
if (mb.parentId === current) {
ids.push(mb.id);
stack.push(mb.id);
}
}
}
return ids;
};
try {
const ids = collectIds(mailboxId);
let total = 0;
for (const id of ids) {
total += await markMailboxAsRead(client, id);
}
await fetchMailboxes(client);
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkAllFoldersRead = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
variant: "default",
});
if (!confirmed) return;
try {
const total = await client.markAllAsRead();
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('email_list.empty_folder.confirm_title'),
message: tCtxMenu('email_list.empty_folder.confirm_message'),
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
try {
await emptyMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
}
};
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name, parentId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleCreateFolderFromContextMenu = async () => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_folder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.rename'),
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
defaultValue: mailbox.name,
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
});
if (!name || name === mailbox.name) return;
try {
await renameMailbox(client, mailboxId, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
}
};
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
if (jmapType === 'mailboxHasChild') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
} else if (jmapType === 'mailboxHasEmail') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
} else {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
}
}
};
const handleRefreshMailboxes = async () => {
if (!client) return;
try {
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
} catch {
// silent
}
};
const handleLogout = logout; const handleLogout = logout;
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
@@ -1269,15 +1495,11 @@ export default function Home() {
}; };
// Handle back navigation from viewer on mobile. // Handle back navigation from viewer on mobile.
// Delegate to the browser history stack so this button is equivalent to // Reset to list state directly. We can't just call window.history.back()
// the OS back button / mouse back button - popstate then restores the // because the nav hook pushes a new entry for every email the user opens,
// previous snapshot via handleNavRestore. The viewer is only reachable // so history.back() would pop to the previous email rather than the list.
// from a state that pushed history, so back() always lands on an app entry. // The OS / hardware back button is still wired through popstate → handleNavRestore.
const handleMobileBack = () => { const handleMobileBack = () => {
if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) { if (conversationThread) {
setConversationThread(null); setConversationThread(null);
setConversationEmails([]); setConversationEmails([]);
@@ -1432,6 +1654,15 @@ export default function Home() {
onMailboxSelect={handleMailboxSelect} onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect} onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick} onUnreadFilterClick={handleUnreadFilterClick}
onMarkFolderRead={handleMarkFolderRead}
onMarkFolderTreeRead={handleMarkFolderTreeRead}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={handleEmptyFolderFromContextMenu}
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => { onCompose={() => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
@@ -1492,9 +1723,12 @@ export default function Home() {
} else { } else {
selectAllEmails(); selectAllEmails();
} }
} else { } else if (emails.length > 0) {
// Enter selection mode by selecting the first email const currentId = selectedEmail?.id;
if (emails.length > 0) toggleEmailSelection(emails[0].id); const target = currentId && emails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
toggleEmailSelection(target);
} }
}} }}
className={cn( className={cn(
@@ -1898,14 +2132,6 @@ export default function Home() {
/> />
) : ( ) : (
<> <>
{/* Mobile Header for Viewer */}
{isMobile && activeView === "viewer" && (
<MobileViewerHeader
subject={selectedEmail?.subject}
onBack={handleMobileBack}
/>
)}
<ErrorBoundary fallback={EmailViewerErrorFallback}> <ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer <EmailViewer
email={selectedEmail} email={selectedEmail}
@@ -1913,12 +2139,12 @@ export default function Home() {
onReply={handleReply} onReply={handleReply}
onReplyAll={handleReplyAll} onReplyAll={handleReplyAll}
onForward={handleForward} onForward={handleForward}
onDelete={handleDelete} onDelete={() => handleDelete()}
onArchive={() => handleArchive()} onArchive={() => handleArchive()}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam} onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={handleUndoSpam} onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => { onMarkAsRead={async (emailId, read) => {
if (client) { if (client) {
await markAsRead(client, emailId, read); await markAsRead(client, emailId, read);
@@ -1988,6 +2214,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} />
<TotpReauthDialog /> <TotpReauthDialog />
</div> </div>
</DragDropProvider> </DragDropProvider>
+152 -82
View File
@@ -9,7 +9,6 @@ import {
LogOut, LogOut,
Settings as SettingsIcon, Settings as SettingsIcon,
Palette, Palette,
Mail,
User, User,
Shield, Shield,
UserPen, UserPen,
@@ -20,17 +19,27 @@ import {
FolderOpen, FolderOpen,
Tags, Tags,
HardDrive, HardDrive,
Wrench,
BookUser, BookUser,
KeyRound, KeyRound,
PanelLeftClose, PanelLeftClose,
Bell, Bell,
Puzzle, Puzzle,
LayoutGrid,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings'; import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings'; import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
import { ComposingSettings } from '@/components/settings/composing-settings';
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
import { AccountSettings } from '@/components/settings/account-settings'; import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings'; import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings'; import { VacationSettings } from '@/components/settings/vacation-settings';
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings'; import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings'; import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings'; import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { AboutDataSettings } from '@/components/settings/about-data-settings';
import { DebugSettings } from '@/components/settings/debug-settings';
import { FolderSettings } from '@/components/settings/folder-settings'; import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced'; type Tab =
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; | 'account'
| 'language'
| 'notifications'
| 'appearance'
| 'layout'
| 'reading'
| 'composing'
| 'identities'
| 'vacation'
| 'filters'
| 'templates'
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
| 'files'
| 'sidebar_apps'
| 'about_data'
| 'themes'
| 'plugins'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef { interface TabDef {
id: Tab; id: Tab;
@@ -74,28 +109,54 @@ interface TabDef {
} }
const tabIcons: Record<Tab, LucideIcon> = { const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
notifications: Bell,
account: User, account: User,
security: Shield, language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen, identities: UserPen,
encryption: KeyRound,
vacation: PalmtreeIcon, vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter, filters: Filter,
templates: FileText, templates: FileText,
folders: FolderOpen, folders: FolderOpen,
keywords: Tags, keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive, files: HardDrive,
sidebar_apps: PanelLeftClose, sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette, themes: Palette,
plugins: Puzzle, plugins: Puzzle,
advanced: Wrench, debug: Bug,
}; };
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system']; const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
email: 'reading',
advanced: 'about_data',
};
function readPersistedTab(): Tab {
try {
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
const migrated = LEGACY_TAB_MAP[saved];
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
} catch {
return 'appearance';
}
}
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
@@ -107,13 +168,7 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig(); const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(() => { const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [mobileShowContent, setMobileShowContent] = useState(false); const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
@@ -124,21 +179,28 @@ export default function SettingsPage() {
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256); const dragStartWidth = useRef(256);
// Check auth on mount // Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
}, [checkAuth]); }, [checkAuth]);
// Listen for tab change events from child components // Listen for tab change events from child components (with legacy migration)
useEffect(() => { useEffect(() => {
const handler = (e: Event) => { const handler = (e: Event) => {
const tab = (e as CustomEvent).detail as Tab; const raw = (e as CustomEvent).detail as string;
if (tab) { if (!raw) return;
setActiveTab(tab); const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ } setActiveTab(tab);
} try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
}; };
window.addEventListener('settings-tab-change', handler); window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler); return () => window.removeEventListener('settings-tab-change', handler);
@@ -161,25 +223,41 @@ export default function SettingsPage() {
const supportsFiles = client?.supportsFiles() ?? false; const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [ const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' }, // General
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' }, { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), // Appearance
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []), { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []), // Mail
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []), { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []), { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' }, { id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []), // Advanced
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
]; ];
// Group tabs by category // Group tabs by category
@@ -191,6 +269,10 @@ export default function SettingsPage() {
})) }))
.filter((g) => g.items.length > 0); .filter((g) => g.items.length > 0);
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
const handleTabSelect = (tabId: Tab) => { const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId); setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
@@ -199,39 +281,42 @@ export default function SettingsPage() {
} }
}; };
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? ''; const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => ( const renderTabContent = () => (
<> <>
{activeTab === 'appearance' && <AppearanceSettings />} {effectiveActiveTab === 'account' && <AccountSettings />}
{activeTab === 'email' && <EmailSettings />} {effectiveActiveTab === 'language' && <LanguageSettings />}
{activeTab === 'notifications' && <NotificationSettings />} {effectiveActiveTab === 'notifications' && <NotificationSettings />}
{activeTab === 'account' && <AccountSettings />} {effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'security' && <AccountSecuritySettings />} {effectiveActiveTab === 'layout' && <LayoutSettings />}
{activeTab === 'identities' && <IdentitySettings />} {effectiveActiveTab === 'reading' && <ReadingSettings />}
{activeTab === 'encryption' && <SmimeSettings />} {effectiveActiveTab === 'composing' && <ComposingSettings />}
{activeTab === 'vacation' && <VacationSettings />} {effectiveActiveTab === 'identities' && <IdentitySettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {effectiveActiveTab === 'vacation' && <VacationSettings />}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>} {effectiveActiveTab === 'filters' && <FilterSettings />}
{activeTab === 'filters' && <FilterSettings />} {effectiveActiveTab === 'templates' && <TemplateSettings />}
{activeTab === 'templates' && <TemplateSettings />} {effectiveActiveTab === 'folders' && <FolderSettings />}
{activeTab === 'folders' && <FolderSettings />} {effectiveActiveTab === 'keywords' && <KeywordSettings />}
{activeTab === 'keywords' && <KeywordSettings />} {effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'files' && <FilesSettingsComponent />} {effectiveActiveTab === 'encryption' && <SmimeSettings />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />} {effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{activeTab === 'themes' && <ThemesSettings />} {effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'plugins' && <PluginsSettings />} {effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'advanced' && <AdvancedSettings />} {effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</> </>
); );
// Mobile layout // Mobile layout
if (!isDesktop) { if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) { if (mobileShowContent) {
return ( return (
<div className="flex flex-col h-dvh bg-background"> <div className="flex flex-col h-dvh bg-background">
{/* Mobile content header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -244,14 +329,12 @@ export default function SettingsPage() {
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1> <h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div> </div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()} {renderTabContent()}
</div> </div>
</div> </div>
{/* Bottom Navigation */}
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -264,10 +347,8 @@ export default function SettingsPage() {
); );
} }
// Mobile: show tab list
return ( return (
<div className="flex flex-col h-dvh bg-background"> <div className="flex flex-col h-dvh bg-background">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -283,7 +364,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="py-2"> <div className="py-2">
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
@@ -319,7 +399,6 @@ export default function SettingsPage() {
))} ))}
</div> </div>
{/* Logout */}
<div className="border-t border-border px-5 py-3"> <div className="border-t border-border px-5 py-3">
<button <button
onClick={logout} onClick={logout}
@@ -331,7 +410,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Bottom Navigation */}
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -347,7 +425,6 @@ export default function SettingsPage() {
// Desktop layout // Desktop layout
return ( return (
<div className="flex h-dvh bg-background"> <div className="flex h-dvh bg-background">
{/* Navigation Rail */}
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -366,7 +443,6 @@ export default function SettingsPage() {
)} )}
{!inlineApp && ( {!inlineApp && (
<> <>
{/* Settings Sidebar */}
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary flex flex-col", "border-r border-border bg-secondary flex flex-col",
@@ -374,7 +450,6 @@ export default function SettingsPage() {
)} )}
style={{ width: `${settingsSidebarWidth}px` }} style={{ width: `${settingsSidebarWidth}px` }}
> >
{/* Header */}
<div className="p-4 border-b border-border"> <div className="p-4 border-b border-border">
<Button <Button
variant="ghost" variant="ghost"
@@ -387,7 +462,6 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs"> <div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5"> <div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
@@ -406,14 +480,14 @@ export default function SettingsPage() {
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', 'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium' ? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground' : 'hover:bg-muted text-foreground'
)} )}
> >
<Icon className={cn( <Icon className={cn(
'w-4 h-4 shrink-0', 'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground' effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} /> )} />
{tab.label} {tab.label}
{tab.experimental && ( {tab.experimental && (
@@ -430,7 +504,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Sidebar resize handle */}
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
@@ -441,10 +514,8 @@ export default function SettingsPage() {
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }} onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/> />
{/* Settings Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8"> <div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-2.5 mb-2"> <div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" /> <SettingsIcon className="w-6 h-6 text-muted-foreground" />
@@ -452,7 +523,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6"> <div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()} {renderTabContent()}
</div> </div>
+166 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react'; import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
@@ -69,6 +69,58 @@ export default function AdminAuthPage() {
} }
} }
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0; const hasEdits = Object.keys(edits).length > 0;
if (loading) { if (loading) {
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
</div> </div>
)} )}
{/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
</p>
</div>
<button
onClick={openSetupDialog}
disabled={setupRunning}
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</button>
</div>
</div>
{/* Auto-setup dialog */}
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable OAuth only (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<button
onClick={() => setSetupOpen(false)}
disabled={setupRunning}
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</button>
</div>
</div>
</div>
)}
{/* OAuth */} {/* OAuth */}
<Section title="OAuth / OpenID Connect"> <Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
+57 -48
View File
@@ -65,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null); const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
@@ -73,54 +74,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => { useEffect(() => {
if (pathname !== '/admin/login') { if (pathname === '/admin/login') return;
checkAuth(); let cancelled = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
function getJmapHeaders(): Record<string, string> { async function checkAuth() {
return getActiveAccountSlotHeaders(); try {
} const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
async function checkAuth() { const stalwartAdmin = data.stalwartAdmin === true;
try { setIsStalwartAdmin(stalwartAdmin);
const jmapHeaders = getJmapHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
const stalwartAdmin = data.stalwartAdmin === true; // If neither password-based admin nor Stalwart admin, redirect away
setIsStalwartAdmin(stalwartAdmin); if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
// If neither password-based admin nor Stalwart admin, redirect away if (data.authenticated) {
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (loginRes.ok) {
setAuthenticated(true); setAuthenticated(true);
return; return;
} }
}
router.replace('/admin/login'); // If Stalwart admin but not yet authenticated, auto-login
} catch { if (stalwartAdmin) {
router.replace('/admin/login'); const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (cancelled) return;
if (loginRes.ok) {
setAuthenticated(true);
return;
}
const body = await loginRes.json().catch(() => ({}));
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
setAuthenticated(false);
return;
}
router.replace('/admin/login');
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
} }
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router]);
async function handleLogout() { async function handleLogout() {
await apiFetch('/api/admin/auth', { method: 'DELETE' }); await apiFetch('/api/admin/auth', { method: 'DELETE' });
@@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>; return <>{children}</>;
} }
if (authenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
</div>
);
}
return ( return (
<div className="min-h-screen flex bg-background"> <div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */} {/* Slim webmail nav rail */}
@@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Main content */} {/* Main content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">
{children} {authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div> </div>
</main> </main>
</div> </div>
Binary file not shown.
+253
View File
@@ -0,0 +1,253 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { locales as ALL_LOCALES } from '@/i18n/routing';
const CLIENT_ID = 'bulwark-webmail';
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
const JMAP_TIMEOUT_MS = 10_000;
interface JmapMethodCall {
using: string[];
methodCalls: Array<[string, Record<string, unknown>, string]>;
}
interface JmapMethodResponse {
methodResponses?: Array<[string, Record<string, unknown>, string]>;
}
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function jmapCall(
serverUrl: string,
authHeader: string,
body: JmapMethodCall,
): Promise<JmapMethodResponse> {
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
method: 'POST',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
}
return res.json() as Promise<JmapMethodResponse>;
}
async function getStalwartAccountId(
serverUrl: string,
authHeader: string,
): Promise<string | null> {
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': authHeader },
});
if (!res.ok) return null;
const session = await res.json() as { primaryAccounts?: Record<string, string> };
return session.primaryAccounts?.['urn:stalwart:jmap']
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
?? Object.values(session.primaryAccounts ?? {})[0]
?? null;
}
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
const out: Record<string, true> = {};
for (const loc of localeList) {
out[`${origin}/${loc}/auth/callback`] = true;
}
return out;
}
interface SetupRequestBody {
origin?: string;
issuerUrl?: string;
locales?: string[];
oauthOnly?: boolean;
}
function isValidOriginUrl(value: string): boolean {
return /^https?:\/\/[^/]+$/.test(value);
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json(
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
{ status: 400 },
);
}
const body = await request.json() as SetupRequestBody;
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
if (!isValidOriginUrl(origin)) {
return NextResponse.json(
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
{ status: 400 },
);
}
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
if (!isValidOriginUrl(issuerUrl)) {
return NextResponse.json(
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
{ status: 400 },
);
}
const localeList = Array.isArray(body.locales) && body.locales.length > 0
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
: Array.from(ALL_LOCALES);
if (localeList.length === 0) {
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
}
const oauthOnly = body.oauthOnly === true;
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
if (!accountId) {
return NextResponse.json(
{ error: 'Could not resolve Stalwart account from JMAP session.' },
{ status: 502 },
);
}
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [[
'x:OAuthClient/query',
{ accountId, filter: { clientId: CLIENT_ID } },
'0',
]],
});
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.',
detail: queryEntry?.[1],
}, { status: 403 });
}
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
const secret = randomBytes(32).toString('base64url');
const redirectUris = buildRedirectUris(origin, localeList);
let setArgs: Record<string, unknown>;
let action: 'created' | 'updated';
if (existingIds.length > 0) {
const targetId = existingIds[0];
action = 'updated';
setArgs = {
accountId,
update: {
[targetId]: {
secret,
redirectUris,
description: CLIENT_DESCRIPTION,
},
},
};
} else {
action = 'created';
setArgs = {
accountId,
create: {
new: {
clientId: CLIENT_ID,
description: CLIENT_DESCRIPTION,
secret,
redirectUris,
contacts: { [creds.username]: true },
},
},
};
}
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
});
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/set — admin permissions required.',
detail: setEntry?.[1],
}, { status: 403 });
}
const setBody = setEntry[1] as {
notCreated?: Record<string, unknown>;
notUpdated?: Record<string, unknown>;
};
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
{ status: 502 },
);
}
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
{ status: 502 },
);
}
await configManager.ensureLoaded();
const updates: Record<string, unknown> = {
oauthEnabled: true,
oauthClientId: CLIENT_ID,
oauthClientSecret: secret,
oauthIssuerUrl: issuerUrl,
};
if (oauthOnly) updates.oauthOnly = true;
await configManager.setAdminConfig(updates);
await auditLog('admin.oauth_setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
redirectUriCount: localeList.length,
oauthOnly,
}, ip);
logger.info('Admin OAuth setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
locales: localeList.length,
});
return NextResponse.json({
ok: true,
action,
clientId: CLIENT_ID,
origin,
issuerUrl,
redirectUriCount: localeList.length,
});
} catch (error) {
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 },
);
}
}
+4 -1
View File
@@ -9,6 +9,7 @@ import {
clearStalwartAuthContextInStore, clearStalwartAuthContextInStore,
setStalwartAuthContextInStore, setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context'; } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') { const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
if (oauthEnabled && oauthOnly) {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 }); return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
} }
+3 -2
View File
@@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -113,8 +114,8 @@ async function attemptAllStrategies(
): Promise<NextResponse> { ): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID; const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = []; const attempts: Array<{ strategy: string; error: string }> = [];
+1 -2
View File
@@ -112,10 +112,9 @@ const emails: MockEmail[] = [
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.', preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }], htmlBody: [],
bodyValues: { bodyValues: {
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' }, p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
p2: { value: '<p>Hallo!</p><p>This is a sample email to help you get started with the <strong>Bulwark Webmail</strong> development environment.</p><p>Feel free to explore the UI - all data here is mock data.</p><p>Beste Grüße,<br>Sophie</p>' },
}, },
}, },
{ {
+16 -3
View File
@@ -119,6 +119,7 @@ export function CalendarDayView({
created: t("notifications.event_created"), created: t("notifications.event_created"),
error: t("notifications.event_error"), error: t("notifications.event_error"),
}, },
isMobile,
}); });
const formatHour = (h: number): string => { const formatHour = (h: number): string => {
@@ -246,9 +247,11 @@ export function CalendarDayView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes); const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT; const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100; const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100;
@@ -270,11 +273,21 @@ export function CalendarDayView({
onContextMenu={onContextMenuEvent} onContextMenu={onContextMenuEvent}
draggable draggable
/> />
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
<div <div
data-resize-handle data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity" className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")} aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)} onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove} onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp} onPointerUp={handleResizePointerUp}
> >
+7 -4
View File
@@ -260,6 +260,9 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */} {/* ── DESKTOP TOOLBAR ── */}
{!isMobile && ( {!isMobile && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</Button> </Button>
@@ -279,14 +282,14 @@ export function CalendarToolbar({
<div className="flex-1" /> <div className="flex-1" />
{!isMobile && ( {!isMobile && (
<div className="flex border border-border rounded-md overflow-hidden"> <div className="flex h-8 border border-border rounded-md overflow-hidden">
{views.map((v) => ( {views.map((v) => (
<button <button
key={v} key={v}
onClick={() => onViewModeChange(v)} onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)} title={t(`views.${v}_hint`)}
className={cn( className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors", "inline-flex items-center px-3 text-xs font-medium transition-colors",
v === viewMode v === viewMode
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground" : "hover:bg-muted text-muted-foreground"
@@ -300,7 +303,7 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && ( {(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}> <div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}> <Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" /> <Upload className="w-4 h-4 mr-1" />
{t("import.title")} {t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" /> <ChevronDown className="w-3 h-3 ml-1" />
@@ -331,7 +334,7 @@ export function CalendarToolbar({
)} )}
{!isMobile && ( {!isMobile && (
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button"> <Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
{t("events.create")} {t("events.create")}
</Button> </Button>
+16 -3
View File
@@ -183,6 +183,7 @@ export function CalendarWeekView({
created: t("notifications.event_created"), created: t("notifications.event_created"),
error: t("notifications.event_error"), error: t("notifications.event_error"),
}, },
isMobile,
}); });
const formatHour = (h: number): string => { const formatHour = (h: number): string => {
@@ -384,9 +385,11 @@ export function CalendarWeekView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes); const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT; const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100; const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100;
@@ -408,11 +411,21 @@ export function CalendarWeekView({
onContextMenu={onContextMenuEvent} onContextMenu={onContextMenuEvent}
draggable draggable
/> />
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
<div <div
data-resize-handle data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity" className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")} aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)} onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove} onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp} onPointerUp={handleResizePointerUp}
> >
+21 -8
View File
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]); }, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setTimeout(() => { const trimmed = query.trim();
setShowSuggestions(false); if (trimmed && EMAIL_REGEX.test(trimmed)) {
const trimmed = query.trim(); addParticipant({ name: "", email: trimmed });
if (trimmed && EMAIL_REGEX.test(trimmed)) { }
addParticipant({ name: "", email: trimmed }); setTimeout(() => setShowSuggestions(false), 200);
}
}, 200);
}, [query, addParticipant]); }, [query, addParticipant]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
key={`${p.email}-${i}`} key={`${p.email}-${i}`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]" className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
> >
<span className="truncate">{p.name || p.email}</span> {!disabled ? (
<button
type="button"
onClick={() => {
onRemove(p.email);
setQuery(p.email);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
aria-label={`${t("edit")} ${p.name || p.email}`}
>
{p.name || p.email}
</button>
) : (
<span className="truncate">{p.name || p.email}</span>
)}
{!disabled && ( {!disabled && (
<button <button
type="button" type="button"
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => { it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn(); const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />); render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const deleteButton = screen.getAllByRole('button').find( fireEvent.click(screen.getByLabelText('detail.more_actions'));
btn => btn.className.includes('text-red') fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
);
fireEvent.click(deleteButton!);
expect(onDelete).toHaveBeenCalledOnce(); expect(onDelete).toHaveBeenCalledOnce();
}); });
}); });
@@ -36,7 +36,6 @@ const defaultProps = {
onSearchChange: vi.fn(), onSearchChange: vi.fn(),
onSelectContact: vi.fn(), onSelectContact: vi.fn(),
onCreateNew: vi.fn(), onCreateNew: vi.fn(),
categoryLabel: 'All Contacts',
selectedContactIds: new Set<string>(), selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(), onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(), onSelectRangeContacts: vi.fn(),
@@ -79,8 +78,4 @@ describe('ContactList', () => {
expect(screen.getByText('bulk.export')).toBeInTheDocument(); expect(screen.getByText('bulk.export')).toBeInTheDocument();
}); });
it('shows category label with count', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
});
}); });
+117 -91
View File
@@ -2,12 +2,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Mail, CalendarDays, Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types"; import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5; const EMAIL_LIMIT = 5;
@@ -43,14 +44,15 @@ function buildEmailFilter(addresses: string[]): Record<string, unknown> {
} }
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean { function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (!event.participants) return false; if (event.participants) {
for (const p of Object.values(event.participants)) { for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase(); const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true; if (email && addresses.has(email)) return true;
if (p.sendTo) { if (p.sendTo) {
for (const target of Object.values(p.sendTo)) { for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null; const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true; if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
} }
} }
} }
@@ -62,6 +64,26 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
return false; return false;
} }
function getEmailSender(email: Email): { name: string; address: string } {
const from = email.from?.[0];
return {
name: from?.name?.trim() || from?.email || "",
address: from?.email || "",
};
}
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const groups = new Map<string, CalendarEvent[]>();
for (const e of events) {
const d = new Date(e.start);
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
const arr = groups.get(key) || [];
arr.push(e);
groups.set(key, arr);
}
return groups;
}
export function ContactActivity({ contact }: ContactActivityProps) { export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity"); const t = useTranslations("contacts.activity");
const format = useFormatter(); const format = useFormatter();
@@ -170,111 +192,115 @@ export function ContactActivity({ contact }: ContactActivityProps) {
: { year: "numeric", month: "short", day: "numeric" }); : { year: "numeric", month: "short", day: "numeric" });
}; };
const formatEventDate = (event: CalendarEvent) => { const formatEventTime = (event: CalendarEvent) => {
if (event.showWithoutTime) return t("all_day");
const d = new Date(event.start); const d = new Date(event.start);
if (isNaN(d.getTime())) return event.start; if (isNaN(d.getTime())) return "";
if (event.showWithoutTime) { return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
}
return format.dateTime(d, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}; };
const formatEventDateHeader = (isoDate: string) => {
const d = new Date(isoDate);
if (isNaN(d.getTime())) return isoDate;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { weekday: "long", month: "long", day: "numeric" }
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
};
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
return ( return (
<> <div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<ActivitySection icon={Mail} title={t("recent_emails")}> <Section title={t("recent_emails")}>
{emailsLoading ? ( {emailsLoading ? (
<LoadingRow /> <LoadingRow />
) : emailsError ? ( ) : emailsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p> <p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !emails || emails.length === 0 ? ( ) : !emails || emails.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_emails")}</p> <p className="text-sm text-muted-foreground">{t("no_emails")}</p>
) : ( ) : (
emails.map((email) => ( <div className="-mx-2">
<button {emails.map((email) => {
key={email.id} const sender = getEmailSender(email);
type="button" return (
onClick={() => handleOpenEmail(email)} <button
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" key={email.id}
> type="button"
<div className="flex items-baseline justify-between gap-2"> onClick={() => handleOpenEmail(email)}
<span className="text-sm font-medium truncate"> className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
{email.subject || t("no_subject")} >
</span> <Avatar name={sender.name} email={sender.address} size="sm" />
<span className="text-xs text-muted-foreground flex-shrink-0"> <div className="flex-1 min-w-0">
{formatEmailDate(email.receivedAt)} <div className="flex items-baseline justify-between gap-2">
</span> <span className="text-sm font-medium truncate">
</div> {sender.name || t("unknown_sender")}
{email.preview && ( </span>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <span className="text-xs text-muted-foreground flex-shrink-0">
{email.preview} {formatEmailDate(email.receivedAt)}
</p> </span>
)} </div>
</button> <div className="text-sm truncate">
)) {email.subject || t("no_subject")}
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</div>
</button>
);
})}
</div>
)} )}
</ActivitySection> </Section>
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}> <Section title={t("upcoming_events")}>
{eventsLoading ? ( {eventsLoading ? (
<LoadingRow /> <LoadingRow />
) : eventsError ? ( ) : eventsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p> <p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !events || events.length === 0 ? ( ) : !events || events.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_events")}</p> <p className="text-sm text-muted-foreground">{t("no_events")}</p>
) : ( ) : (
events.map((event) => ( <div className="space-y-4">
<button {eventGroups.map(([dateKey, group]) => (
key={event.id} <div key={dateKey}>
type="button" <div className="text-xs font-medium text-muted-foreground mb-1.5">
onClick={() => handleOpenEvent(event)} {formatEventDateHeader(dateKey)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" </div>
> <div className="-mx-2">
<div className="flex items-baseline justify-between gap-2"> {group.map((event) => (
<span className="text-sm font-medium truncate"> <button
{event.title || t("no_title")} key={event.id}
</span> type="button"
<span className="text-xs text-muted-foreground flex-shrink-0"> onClick={() => handleOpenEvent(event)}
{formatEventDate(event)} className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
</span> >
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
{formatEventTime(event)}
</span>
<span className="text-sm truncate flex-1 min-w-0">
{event.title || t("no_title")}
</span>
</button>
))}
</div>
</div> </div>
</button> ))}
)) </div>
)} )}
</ActivitySection> </Section>
</>
);
}
function ActivitySection({
icon: Icon,
title,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
}) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1 pl-6">{children}</div>
</div> </div>
); );
} }
function LoadingRow() { function LoadingRow() {
return ( return (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
</div> </div>
); );
} }
+63 -16
View File
@@ -11,16 +11,25 @@ import {
Eye, Eye,
Pencil, Pencil,
Mail, Mail,
Phone,
ClipboardCopy, ClipboardCopy,
Download, Download,
Users, Users,
Trash2, Trash2,
Copy,
Printer,
} from "lucide-react"; } from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store"; import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export"; import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
if (!contact.phones) return "";
return Object.values(contact.phones)[0]?.number || "";
}
interface Position { interface Position {
x: number; x: number;
y: number; y: number;
@@ -38,6 +47,7 @@ interface ContactContextMenuProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onAddToGroup: () => void; onAddToGroup: () => void;
onDuplicate?: () => void;
onBatchExport?: () => void; onBatchExport?: () => void;
onBatchAddToGroup?: () => void; onBatchAddToGroup?: () => void;
onBatchDelete?: () => void; onBatchDelete?: () => void;
@@ -55,12 +65,14 @@ export function ContactContextMenu({
onEdit, onEdit,
onDelete, onDelete,
onAddToGroup, onAddToGroup,
onDuplicate,
onBatchExport, onBatchExport,
onBatchAddToGroup, onBatchAddToGroup,
onBatchDelete, onBatchDelete,
}: ContactContextMenuProps) { }: ContactContextMenuProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact); const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => { const handle = (fn: () => void) => () => {
@@ -73,10 +85,15 @@ export function ContactContextMenu({
window.location.href = `mailto:${email}`; window.location.href = `mailto:${email}`;
}; };
const handleCopyEmail = async () => { const handleCall = () => {
if (!email) return; if (!phone) return;
window.location.href = `tel:${phone}`;
};
const handleCopy = async (value: string) => {
if (!value) return;
try { try {
await navigator.clipboard.writeText(email); await navigator.clipboard.writeText(value);
toast.success(t("detail.copied")); toast.success(t("detail.copied"));
} catch { } catch {
toast.error(t("detail.copy_failed")); toast.error(t("detail.copy_failed"));
@@ -88,6 +105,10 @@ export function ContactContextMenu({
toast.success(t("export.success", { count: 1 })); toast.success(t("export.success", { count: 1 }));
}; };
const handlePrint = () => {
printContact(contact);
};
if (showBatchActions) { if (showBatchActions) {
return ( return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}> <ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
@@ -122,20 +143,34 @@ export function ContactContextMenu({
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}> <ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} /> <ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} /> <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && ( {email && (
<> <ContextMenuItem
<ContextMenuSeparator /> icon={Mail}
<ContextMenuItem label={t("context_menu.send_email")}
icon={Mail} onClick={handle(handleSendEmail)}
label={t("context_menu.send_email")} />
onClick={handle(handleSendEmail)} )}
/> {phone && (
<ContextMenuItem <ContextMenuItem
icon={ClipboardCopy} icon={Phone}
label={t("detail.copy_email")} label={t("context_menu.call")}
onClick={handle(handleCopyEmail)} onClick={handle(handleCall)}
/> />
</> )}
{email && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(() => handleCopy(email))}
/>
)}
{phone && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_phone")}
onClick={handle(() => handleCopy(phone))}
/>
)} )}
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
@@ -143,11 +178,23 @@ export function ContactContextMenu({
label={t("context_menu.add_to_group")} label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)} onClick={handle(onAddToGroup)}
/> />
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
<ContextMenuItem <ContextMenuItem
icon={Download} icon={Download}
label={t("context_menu.export_vcard")} label={t("context_menu.export_vcard")}
onClick={handle(handleExport)} onClick={handle(handleExport)}
/> />
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
icon={Trash2} icon={Trash2}
+433 -292
View File
@@ -1,23 +1,37 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react"; import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity"; import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types"; import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps { interface ContactDetailProps {
contact: ContactCard | null; contact: ContactCard | null;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
isMobile?: boolean; isMobile?: boolean;
className?: string; className?: string;
} }
@@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", "); return Object.keys(features).filter(k => features[k]).join(", ");
} }
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
const m = month ?? 1;
const d = day ?? 1;
const nowM = now.getMonth() + 1;
const nowD = now.getDate();
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
if (years < 0) return null;
return years;
}
function formatDate(dateInput: AnniversaryDate): string { function formatDate(dateInput: AnniversaryDate): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
if (typeof dateInput === 'object' && dateInput !== null) { if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try { try {
@@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
return String(dateInput.utc); return String(dateInput.utc);
} }
const pd = dateInput as PartialDate; const pd = dateInput as PartialDate;
const year = pd.year;
const month = pd.month;
const day = pd.day;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = []; const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]); if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (day) parts.push(String(day)); if (pd.day) parts.push(String(pd.day));
if (year) parts.push(String(year)); if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput); return parts.join(' ') || String(dateInput);
} }
const dateStr = String(dateInput); const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) { if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-"); const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10); const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined; const day = parts[1] ? parseInt(parts[1], 10) : undefined;
@@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr; return dateStr;
} }
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) { export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const smimeStore = useSmimeStore(); const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map()); const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
@@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try { try {
let derBytes: ArrayBuffer | string | null = null; let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) { if (key.uri.startsWith('data:')) {
// data URI - extract base64 content
const commaIdx = key.uri.indexOf(','); const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue; if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1); const b64 = key.uri.substring(commaIdx + 1);
@@ -97,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer; derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) { } else if (key.uri.startsWith('-----BEGIN')) {
// PEM-encoded certificate inline
derBytes = key.uri; derBytes = key.uri;
} }
if (!derBytes) continue; if (!derBytes) continue;
@@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const name = getContactDisplayName(contact); const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact); const email = getContactPrimaryEmail(contact);
const photoUri = getContactPhotoUri(contact);
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact, name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : []; const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : []; const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : []; const orgs = contact.organizations ? Object.values(contact.organizations) : [];
@@ -171,257 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const hasNickname = nicknames.length > 0; const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined; const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
return ( return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}> <div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}> <div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}> <div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 min-w-0 flex-1">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} /> <Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2> <h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && ( {hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p> <p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)} )}
{titleLine && ( {subtitleParts.length > 0 && (
<p className="text-sm text-muted-foreground truncate">{titleLine}</p> <p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
)}
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
)} )}
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && (
<a
href={`mailto:${email}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
{t("detail.compose_email")}
</a>
)}
{phone && (
<a
href={`tel:${phone}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Phone className="w-4 h-4 mr-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation"> <Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" /> <Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")} {t("form.edit_title")}
</Button> </Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation"> <MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
<Trash2 className="w-4 h-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>
<div className="px-6 py-6"> <div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> {hasContactDetails && (
<ContactActivity contact={contact} /> <Section title={t("detail.section_contact")}>
<div className="space-y-3">
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
{emails.map((e, i) => ( {emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group"> <FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline"> <div className="flex items-center gap-2 group">
{e.address} <a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
</a> {e.address}
{e.contexts && <ContextBadge contexts={e.contexts} />}
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a> </a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} /> <RowActions>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div> </div>
</div> </FieldRow>
))} ))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => { {phones.map((p, i) => {
const featureStr = formatPhoneFeatures(p.features); const features = formatPhoneFeatures(p.features);
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
return ( return (
<div key={i} className="flex items-center gap-2 group"> <FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline"> <div className="flex items-center gap-2 group">
{p.number} <a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
</a> {p.number}
{p.contexts && <ContextBadge contexts={p.contexts} />} </a>
{featureStr && ( <RowActions>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span> <CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
)} </RowActions>
<CopyButton </div>
value={p.number} </FieldRow>
label={t("detail.copy_phone")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
); );
})} })}
</Section>
)}
{(roles.length > 0 || jobTitles.length > 1) && ( {addresses.map((a, i) => {
<Section icon={Briefcase} title={t("detail.titles")} category="work"> const lines: string[] = [];
{titles.map((tl, i) => ( if (a.full || a.fullAddress) {
<div key={i} className="text-sm flex items-center gap-2"> lines.push((a.full || a.fullAddress) as string);
<span>{tl.name}</span> } else if (a.components && a.components.length > 0) {
{tl.kind && ( const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span> if (joined) lines.push(joined);
)} } else {
</div> const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
))} .map(s => (typeof s === "string" ? s.trim() : ""))
</Section> .filter(Boolean) as string[];
)} lines.push(...parts);
}
{orgs.length > 0 && ( return (
<Section icon={Building} title={t("detail.organizations")} category="work"> <FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
{orgs.map((o, i) => ( <div className="text-sm space-y-0.5">
<div key={i} className="text-sm"> {lines.map((line, idx) => (
{o.name} <div key={idx}>{line}</div>
{o.units && o.units.length > 0 && ( ))}
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</Section>
)}
{/* Addresses span full width */}
{addresses.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={MapPin} title={t("detail.addresses")} category="location">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{addresses.map((a, i) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{a.full || a.fullAddress
? (a.full || a.fullAddress)
: a.components && a.components.length > 0
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && <ContextBadge contexts={a.contexts} />}
</div>
{a.timeZone && ( {a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div> <div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)} )}
</div> </div>
))} </FieldRow>
</div> );
</Section> })}
</div>
)}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => ( {onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group"> <FieldRow
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( key={`os${i}`}
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all"> icon={Globe}
{svc.user || svc.uri} label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
</a> >
) : ( <div className="flex items-center gap-2 group">
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span> {typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
)} <a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.service && ( {svc.user || svc.uri}
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span> </a>
)} ) : (
{svc.contexts && <ContextBadge contexts={svc.contexts} />} <span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
<CopyButton )}
value={svc.user || svc.uri} <RowActions>
label={t("detail.copy_url")} <CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
successMsg={t("detail.copied")} </RowActions>
failMsg={t("detail.copy_failed")} </div>
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"} </FieldRow>
/>
</div>
))} ))}
</Section> </div>
)} </Section>
)}
{anniversaries.length > 0 && ( {hasWork && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal"> <Section title={t("detail.section_work")}>
{anniversaries.map((ann, i) => ( <div className="space-y-3">
<div key={i} className="flex items-center gap-2 text-sm"> {orgs.map((o, i) => (
<span>{formatDate(ann.date)}</span> <FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> <div className="text-sm">
{t(`detail.anniversary_${ann.kind}`)} {o.name}
</span> {o.units && o.units.length > 0 && (
</div> <span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
</FieldRow>
))} ))}
</Section> {titles.map((tl, i) => (
)} <FieldRow
key={`tl${i}`}
{personalInfo.length > 0 && ( icon={Briefcase}
<Section icon={Heart} title={t("detail.personal_info")} category="personal"> label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
{personalInfo.map((pi, i) => ( >
<div key={i} className="flex items-center gap-2 text-sm"> <div className="text-sm">{tl.name}</div>
<span>{pi.value}</span> </FieldRow>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
{pi.level && (
<span className="text-xs text-muted-foreground">({pi.level})</span>
)}
</div>
))} ))}
</Section> </div>
)} </Section>
)}
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && ( {hasPersonal && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal"> <Section title={t("detail.section_personal")}>
<div className="text-sm"> <div className="space-y-3">
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>} {anniversaries.map((ann, i) => {
{contact.speakToAs.pronouns && (() => { const years = getCompletedYears(ann.date);
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
return firstPronoun ? ( return (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span> <FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
) : null; <div className="text-sm">
})()} {formatDate(ann.date)}
</div> {years !== null && (
</Section> <span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)} )}
</div>
{preferredLanguages.length > 0 && ( </FieldRow>
<Section icon={Languages} title={t("detail.languages")} category="personal"> );
})}
{hasGender && (
<FieldRow icon={UserCircle} label={t("detail.gender")}>
<div className="text-sm">
{contact.speakToAs?.grammaticalGender && (
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
)}
{contact.speakToAs?.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</FieldRow>
)}
{preferredLanguages.map((lang, i) => ( {preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm"> <FieldRow
<span>{lang.language}</span> key={`lg${i}`}
{lang.contexts && <ContextBadge contexts={lang.contexts} />} icon={Languages}
</div> label={formatContexts(lang.contexts) || t("detail.language_label")}
>
<div className="text-sm">{lang.language}</div>
</FieldRow>
))} ))}
</Section> {personalInfo.map((pi, i) => (
)} <FieldRow
key={`pi${i}`}
icon={Heart}
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
>
<div className="text-sm">{pi.value}</div>
</FieldRow>
))}
</div>
</Section>
)}
{keywords.length > 0 && ( {keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital"> <Section title={t("detail.categories")}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => ( {keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary"> <span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw} {kw}
</span> </span>
))} ))}
</div> </div>
</Section> </Section>
)} )}
{relatedTo.length > 0 && ( {relatedTo.length > 0 && (
<Section icon={Users} title={t("detail.related_contacts")} category="personal"> <Section title={t("detail.related_contacts")}>
<div className="space-y-2">
{relatedTo.map(([uri, rel], i) => { {relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined; const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return ( return (
<div key={i} className="flex items-center gap-2 text-sm"> <FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
<span>{uri}</span> <div className="text-sm break-all">{uri}</div>
{relType && ( </FieldRow>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
); );
})} })}
</Section> </div>
)} </Section>
)}
{cryptoKeys.length > 0 && ( {cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital"> <Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{cryptoKeys.map((key, i) => { {cryptoKeys.map((key, i) => {
const certInfo = parsedCerts.get(i); const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
@@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
: false; : false;
return ( return (
<div key={i} className="p-3 rounded-lg border border-border space-y-1"> <div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? ( {certInfo ? (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -451,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>} {certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div> </div>
{!alreadyImported && ( {!alreadyImported && (
<Button <Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
variant="ghost"
size="sm"
className="ml-4 mt-1"
onClick={() => handleImportContactCert(i)}
>
<Download className="w-3 h-3 mr-1" /> <Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")} {t("detail.import_to_smime")}
</Button> </Button>
@@ -466,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
)} )}
</> </>
) : ( ) : (
<div className="text-sm break-all"> <div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? ( {typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline"> <a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri} {key.uri}
@@ -479,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</div> </div>
); );
})} })}
</Section> </div>
)} </Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={Calendar} title={t("detail.calendar")} category="calendar"> <Section title={t("detail.calendar")}>
<div className="space-y-3">
{contact.calendarUri && ( {contact.calendarUri && (
<div className="text-sm"> <FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span> <a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a> {contact.calendarUri}
</div> </a>
</FieldRow>
)} )}
{contact.schedulingUri && ( {contact.schedulingUri && (
<div className="text-sm"> <FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span> <a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a> {contact.schedulingUri}
</div> </a>
</FieldRow>
)} )}
{contact.freeBusyUri && ( {contact.freeBusyUri && (
<div className="text-sm"> <FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span> <a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a> {contact.freeBusyUri}
</div> </a>
</FieldRow>
)} )}
</Section> </div>
)} </Section>
)}
{/* Notes span full width */} {notes.length > 0 && (
{notes.length > 0 && ( <Section title={t("detail.notes")}>
<div className="md:col-span-2 xl:col-span-3"> <div className="flex items-start gap-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes"> <StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="text-sm space-y-2 flex-1 min-w-0">
{notes.map((n, i) => ( {notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p> <p key={i} className="whitespace-pre-wrap">{n.note}</p>
))} ))}
</Section> </div>
</div> </div>
)} </Section>
)}
{/* Timestamps span full width */} <ContactActivity contact={contact} />
{(contact.created || contact.updated) && (
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1"> {(contact.created || contact.updated) && (
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>} <div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>} {contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
</div> {contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
)} </div>
)}
</div>
</div>
);
}
function formatContexts(contexts?: Record<string, boolean>): string {
if (!contexts) return "";
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
}
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn("py-6", className)}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
{children}
</section>
);
}
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
{children}
</div>
</div>
);
}
function RowActions({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{children}
</div>
);
}
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((o) => !o)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
className="touch-manipulation"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
>
{items.map((item, i) => {
if (item.separator) {
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
}
return (
<button
key={i}
type="button"
role="menuitem"
onClick={() => {
item.onClick();
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
<span className="flex-1">{item.label}</span>
</button>
);
})}
</div> </div>
</div> )}
</div> </div>
); );
} }
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const categoryStyles: Record<SectionCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1.5 pl-6">{children}</div>
</div>
);
}
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
const labels = Object.keys(contexts).filter(k => contexts[k]);
if (labels.length === 0) return null;
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
{labels.join(", ")}
</span>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) { function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return ( return (
<button <button
@@ -575,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
toast.error(failMsg); toast.error(failMsg);
} }
}} }}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)} className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
title={label} title={label}
aria-label={label} aria-label={label}
> >
+182 -65
View File
@@ -2,11 +2,12 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react"; import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
interface EmailEntry { interface EmailEntry {
address: string; address: string;
@@ -53,55 +54,73 @@ interface ContactFormProps {
onCancel: () => void; onCancel: () => void;
} }
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes"; function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
const formCategoryStyles: Record<FormCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
collapsible?: boolean; collapsible?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
category?: FormCategory;
}) { }) {
const [open, setOpen] = useState(defaultOpen || !collapsible); const [open, setOpen] = useState(defaultOpen);
return ( return (
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}> <section className="py-5">
<button <button
type="button" type="button"
className={cn( className={cn(
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors", "flex items-center gap-2 w-full text-left",
collapsible && "hover:text-muted-foreground cursor-pointer", collapsible ? "cursor-pointer" : "cursor-default"
!collapsible && "cursor-default"
)} )}
onClick={() => collapsible && setOpen(!open)} onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1} tabIndex={collapsible ? 0 : -1}
> >
<Icon className="w-4 h-4 text-muted-foreground shrink-0" /> <Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-left">{title}</span> <h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{collapsible && ( {collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" /> open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)} )}
</button> </button>
{open && ( {(open || !collapsible) && (
<div className="space-y-3 pt-3 pb-1"> <div className="space-y-3 mt-3">
{children} {children}
</div> </div>
)} )}
</div> </section>
); );
} }
const MAX_PHOTO_DIM = 512;
const PHOTO_QUALITY = 0.85;
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * ratio));
const h = Math.max(1, Math.round(img.height * ratio));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("canvas-unsupported"));
return;
}
ctx.drawImage(img, 0, 0, w, h);
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
resolve({ uri, mediaType: "image/jpeg" });
};
img.onerror = () => reject(new Error("invalid-image"));
img.src = reader.result as string;
};
reader.onerror = () => reject(new Error("read-failed"));
reader.readAsDataURL(file);
});
}
function Select({ value, onChange, children, className }: { function Select({ value, onChange, children, className }: {
value: string; value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
@@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}, [contact]); }, [contact]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId); const [selectedBookId, setSelectedBookId] = useState(currentBookId);
const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
}
return null;
}, [contact]);
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
const [photoError, setPhotoError] = useState<string | null>(null);
const [photoUploading, setPhotoUploading] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({}); const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setPhotoError(t("photo_invalid"));
return;
}
if (file.size > 10 * 1024 * 1024) {
setPhotoError(t("photo_too_large"));
return;
}
setPhotoError(null);
setPhotoUploading(true);
try {
const { uri, mediaType } = await processImageFile(file);
setPhotoUri(uri);
setPhotoMediaType(mediaType);
} catch {
setPhotoError(t("photo_invalid"));
} finally {
setPhotoUploading(false);
}
};
const handlePhotoRemove = () => {
setPhotoUri(undefined);
setPhotoMediaType(undefined);
setPhotoError(null);
};
const validateEmail = (address: string): boolean => { const validateEmail = (address: string): boolean => {
if (!address.trim()) return true; if (!address.trim()) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim()); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
@@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}); });
} }
const mediaMap: Record<string, ContactMedia> = {};
if (contact?.media) {
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind !== "photo") mediaMap[key] = m;
}
}
if (photoUri) {
const photoKey = initialPhotoEntry?.key || "photo";
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
}
const data: Partial<ContactCard> = { const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true }, name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
@@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
calendarUri: calendarUri.trim() || undefined, calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
}; };
@@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
} }
}; };
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background"> <form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
@@ -478,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-4"> <div className="px-6 py-4 max-w-3xl">
{error && ( {error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4"> <div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
{error} {error}
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="flex items-center gap-4 pb-4">
<button
{/* Address Book Selector */} type="button"
{addressBooks && addressBooks.length > 1 && ( onClick={() => photoInputRef.current?.click()}
<div className="md:col-span-2 xl:col-span-3"> disabled={photoUploading}
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact"> className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
<select title={t("upload_photo")}
value={selectedBookId} aria-label={t("upload_photo")}
onChange={(e) => setSelectedBookId(e.target.value)} >
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" <Avatar
name={previewName || undefined}
email={previewEmail || undefined}
contactPhotoUri={photoUri}
size="lg"
className="!w-20 !h-20 !text-xl"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
>
<Camera className="w-5 h-5" />
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
</span>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoSelect}
/>
</button>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
{photoError && (
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
)}
{photoUri && (
<button
type="button"
onClick={handlePhotoRemove}
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
> >
<option value="">{t("select_address_book") || "Select a directory..."}</option> <Trash2 className="w-3 h-3" />
{addressBooks.map((book) => ( {t("remove_photo")}
<option key={book.id} value={book.id}> </button>
{book.accountName ? `${book.name} (${book.accountName})` : book.name} )}
</option>
))}
</select>
</FormSection>
</div> </div>
</div>
<div className="divide-y divide-border/60">
{addressBooks && addressBooks.length > 1 && (
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
)} )}
{/* Name & Identity - full width */} <FormSection icon={User} title={t("section_identity")}>
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2"> <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label> <label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
</div> </div>
</FormSection> </FormSection>
</div>
{/* Email */} {/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact"> <FormSection icon={Mail} title={t("email")}>
<div className="space-y-2"> <div className="space-y-2">
{emails.map((entry, i) => ( {emails.map((entry, i) => (
<div key={i}> <div key={i}>
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Phone */} {/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact"> <FormSection icon={Phone} title={t("phone")}>
<div className="space-y-2"> <div className="space-y-2">
{phones.map((entry, i) => ( {phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Work & Organization */} {/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work"> <FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label> <label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
</FormSection> </FormSection>
{/* Addresses - full width */} {/* Addresses */}
<div className="md:col-span-2 xl:col-span-3"> <FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
<div className="space-y-3"> <div className="space-y-3">
{addresses.map((addr, i) => ( {addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative"> <div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</Button> </Button>
</div> </div>
</FormSection> </FormSection>
</div>
{/* Online Services */} {/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital"> <FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
<div className="space-y-2"> <div className="space-y-2">
{onlineServices.map((svc, i) => ( {onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Anniversaries */} {/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal"> <FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
<div className="space-y-2"> <div className="space-y-2">
{anniversaries.map((ann, i) => ( {anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Personal Info */} {/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal"> <FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
<div className="space-y-2"> <div className="space-y-2">
{personalInfoEntries.map((pi, i) => ( {personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Categories */} {/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital"> <FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox <CategoryComboBox
keywordsStr={keywordsStr} keywordsStr={keywordsStr}
onChange={setKeywordsStr} onChange={setKeywordsStr}
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Gender */} {/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal"> <FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label> <label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Calendar */} {/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar"> <FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label> <label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div> </div>
</FormSection> </FormSection>
{/* Notes - full width */} {/* Notes */}
<div className="md:col-span-2 xl:col-span-3"> <FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
@@ -876,7 +994,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring" className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/> />
</FormSection> </FormSection>
</div>
</div> </div>
</div> </div>
+409 -56
View File
@@ -1,18 +1,98 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react"; import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu"; import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
type TriState = boolean | null;
interface ListFilters {
organization: string;
jobTitle: string;
location: string;
emailDomain: string;
birthdayMonth: number | null;
hasEmail: TriState;
hasPhone: TriState;
hasPhoto: TriState;
}
const EMPTY_FILTERS: ListFilters = {
organization: "",
jobTitle: "",
location: "",
emailDomain: "",
birthdayMonth: null,
hasEmail: null,
hasPhone: null,
hasPhoto: null,
};
function cycleTri(v: TriState): TriState {
return v === null ? true : v === true ? false : null;
}
function countActiveFilters(f: ListFilters): number {
let n = 0;
if (f.organization.trim()) n++;
if (f.jobTitle.trim()) n++;
if (f.location.trim()) n++;
if (f.emailDomain.trim()) n++;
if (f.birthdayMonth !== null) n++;
if (f.hasEmail !== null) n++;
if (f.hasPhone !== null) n++;
if (f.hasPhoto !== null) n++;
return n;
}
function matchTri(actual: boolean, filter: TriState): boolean {
if (filter === null) return true;
return actual === filter;
}
function getAnniversaryMonth(date: AnniversaryDate): number | null {
if (typeof date === "string") {
const iso = date.match(/^(\d{4})-(\d{2})/);
if (iso) return parseInt(iso[2], 10);
const partial = date.match(/^--(\d{2})/);
if (partial) return parseInt(partial[1], 10);
return null;
}
if ("month" in date && date.month) return date.month;
if ("utc" in date && date.utc) {
const d = new Date(date.utc);
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
}
return null;
}
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
value === true && "bg-primary/10 border-primary/30 text-primary",
value === false && "bg-muted border-border text-muted-foreground line-through",
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
)}
>
{icon}
{label}
</button>
);
}
interface ContactListProps { interface ContactListProps {
contacts: ContactCard[]; contacts: ContactCard[];
selectedContactId: string | null; selectedContactId: string | null;
@@ -20,7 +100,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void; onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void; onSelectContact: (id: string) => void;
onCreateNew: () => void; onCreateNew: () => void;
categoryLabel: string;
className?: string; className?: string;
selectedContactIds: Set<string>; selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void; onToggleSelection: (id: string) => void;
@@ -42,7 +121,6 @@ export function ContactList({
onSearchChange, onSearchChange,
onSelectContact, onSelectContact,
onCreateNew, onCreateNew,
categoryLabel,
className, className,
selectedContactIds, selectedContactIds,
onToggleSelection, onToggleSelection,
@@ -57,31 +135,94 @@ export function ContactList({
onAddContactToGroup, onAddContactToGroup,
}: ContactListProps) { }: ContactListProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const locale = useLocale();
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>(); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
const monthNames = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
}, [locale]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchQuery) return contacts; const lower = searchQuery.trim().toLowerCase();
const lower = searchQuery.toLowerCase(); const orgLower = filters.organization.trim().toLowerCase();
const jobLower = filters.jobTitle.trim().toLowerCase();
const locLower = filters.location.trim().toLowerCase();
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
return contacts.filter((c) => { return contacts.filter((c) => {
const emails = c.emails ? Object.values(c.emails) : [];
const phones = c.phones ? Object.values(c.phones) : [];
const orgs = c.organizations ? Object.values(c.organizations) : [];
const titles = c.titles ? Object.values(c.titles) : [];
const addresses = c.addresses ? Object.values(c.addresses) : [];
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
if (orgLower) {
const match = orgs.some((o) => {
if (o.name?.toLowerCase().includes(orgLower)) return true;
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
return false;
});
if (!match) return false;
}
if (jobLower) {
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
}
if (locLower) {
const match = addresses.some((a) => {
const parts: string[] = [];
if (a.full) parts.push(a.full);
if (a.fullAddress) parts.push(a.fullAddress);
if (a.locality) parts.push(a.locality);
if (a.region) parts.push(a.region);
if (a.country) parts.push(a.country);
if (a.postcode) parts.push(a.postcode);
if (a.street) parts.push(a.street);
if (a.components) {
for (const comp of a.components) {
if (comp.value) parts.push(comp.value);
}
}
return parts.some((p) => p.toLowerCase().includes(locLower));
});
if (!match) return false;
}
if (domainLower) {
const match = emails.some((e) => {
const at = e.address?.toLowerCase().split("@");
return at && at.length > 1 && at[1].includes(domainLower);
});
if (!match) return false;
}
if (filters.birthdayMonth !== null) {
const target = filters.birthdayMonth;
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
if (!match) return false;
}
if (!lower) return true;
const name = getContactDisplayName(c).toLowerCase(); const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails if (name.includes(lower)) return true;
? Object.values(c.emails).map((e) => e.address.toLowerCase()) if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
: []; if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
const phones = c.phones if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "") return false;
: [];
const org = c.organizations
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
: [];
return (
name.includes(lower) ||
emails.some((e) => e.includes(lower)) ||
phones.some((p) => p.includes(lower)) ||
org.some((o) => o.includes(lower))
);
}); });
}, [contacts, searchQuery]); }, [contacts, searchQuery, filters]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...filtered].sort((a, b) => { return [...filtered].sort((a, b) => {
@@ -93,29 +234,214 @@ export function ContactList({
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
// Group sorted contacts by first letter of display name. Non-letter
// starters (digits, symbols, empty) collect under "#" which sorts last.
const groupedSections = useMemo(() => {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
const groups = new Map<string, ContactCard[]>();
for (const contact of sorted) {
const name = getContactDisplayName(contact).trim();
const first = name.charAt(0);
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
? first.toLocaleUpperCase(locale)
: "#";
const bucket = groups.get(letter);
if (bucket) bucket.push(contact);
else groups.set(letter, [contact]);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
if (a === "#") return 1;
if (b === "#") return -1;
return collator.compare(a, b);
})
.map(([letter, items]) => ({ letter, items }));
}, [sorted, locale]);
const hasSelection = selectedContactIds.size > 0; const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return ( return (
<div className={cn("flex flex-col h-full", className)}> <div className={cn("flex flex-col h-full", className)}>
{/* Search header */} {/* Toolbar: select / search / filter */}
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}> <div className="border-b border-border bg-background">
<div className="flex items-center justify-between"> <div className="px-3 py-3">
<span className="text-xs font-medium text-muted-foreground truncate"> <div className="flex items-center gap-1.5">
{categoryLabel} ({contacts.length}) <button
</span> type="button"
</div> onClick={() => {
<div className="relative"> if (hasSelection) {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" /> if (allSelected) onClearSelection();
<Input else onSelectAll(sortedIds);
placeholder={t("search_placeholder")} } else if (sortedIds.length > 0) {
value={searchQuery} const target = selectedContactId && sortedIds.includes(selectedContactId)
onChange={(e) => onSearchChange(e.target.value)} ? selectedContactId
className="pl-8 h-8 text-sm" : sortedIds[0];
/> onToggleSelection(target);
}
}}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
hasSelection
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
>
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
/>
{searchQuery && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
filtersOpen || activeFilters > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("filters.toggle")}
aria-label={t("filters.toggle")}
>
<Filter className="w-4 h-4" />
{!filtersOpen && activeFilters > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{activeFilters}
</span>
)}
</button>
</div>
</div> </div>
</div> </div>
{filtersOpen && (
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
{t("filters.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setFiltersOpen(false)}
className="h-7 w-7"
aria-label={t("filters.close")}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
<Input
value={filters.organization}
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
placeholder={t("filters.organization_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
<Input
value={filters.jobTitle}
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
placeholder={t("filters.job_title_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
<Input
value={filters.location}
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
placeholder={t("filters.location_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
<Input
value={filters.emailDomain}
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
placeholder={t("filters.email_domain_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
<select
value={filters.birthdayMonth ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
aria-label={t("filters.birthday_month")}
>
<option value="">{t("filters.any_month")}</option>
{monthNames.map((name, i) => (
<option key={i} value={i + 1}>{name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ToggleChip
icon={<Mail className="w-3.5 h-3.5" />}
label={t("filters.has_email")}
value={filters.hasEmail}
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
/>
<ToggleChip
icon={<Phone className="w-3.5 h-3.5" />}
label={t("filters.has_phone")}
value={filters.hasPhone}
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
/>
<ToggleChip
icon={<ImageIcon className="w-3.5 h-3.5" />}
label={t("filters.has_photo")}
value={filters.hasPhoto}
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
/>
</div>
</div>
</div>
)}
{/* Bulk action bar */} {/* Bulk action bar */}
{hasSelection && ( {hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap"> <div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
@@ -166,19 +492,32 @@ export function ContactList({
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center"> <div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery ? ( {searchQuery || activeFilters > 0 ? (
<> <>
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" /> {activeFilters > 0 && !searchQuery ? (
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p> <Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p> ) : (
<Button <Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
variant="outline" )}
size="sm" <p className="text-sm font-medium text-foreground">
className="mt-3" {searchQuery ? t("empty_search") : t("empty_filtered")}
onClick={() => onSearchChange("")} </p>
> <p className="text-xs text-muted-foreground mt-1">
{t("clear_search")} {searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
</Button> </p>
<div className="flex gap-2 mt-3">
{searchQuery && (
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
{t("clear_search")}
</Button>
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
{t("filters.clear")}
</Button>
)}
</div>
</> </>
) : ( ) : (
<> <>
@@ -193,8 +532,8 @@ export function ContactList({
)} )}
</div> </div>
) : ( ) : (
<div> (() => {
{sorted.map((contact) => ( const renderItem = (contact: ContactCard) => (
<ContactListItem <ContactListItem
key={contact.id} key={contact.id}
contact={contact} contact={contact}
@@ -221,8 +560,22 @@ export function ContactList({
}} }}
onContextMenu={(e, c) => openContextMenu(e, c)} onContextMenu={(e, c) => openContextMenu(e, c)}
/> />
))} );
</div> return groupByLetter ? (
<div>
{groupedSections.map(({ letter, items }) => (
<section key={letter}>
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
{letter}
</div>
{items.map(renderItem)}
</section>
))}
</div>
) : (
<div>{sorted.map(renderItem)}</div>
);
})()
)} )}
</div> </div>
+120
View File
@@ -0,0 +1,120 @@
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function printContact(contact: ContactCard, displayName?: string): void {
const printWindow = window.open("", "_blank");
if (!printWindow) return;
const name = displayName || getContactDisplayName(contact);
const photoUri = getContactPhotoUri(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const notes = contact.notes ? Object.values(contact.notes) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const rows: string[] = [];
const section = (title: string, items: string[]) => {
if (items.length === 0) return;
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
};
const row = (label: string, value: string) =>
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
section(
"Organization",
orgs.map(o => {
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
return row("Organization", value);
}),
);
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
section(
"Address",
addresses.map(a => {
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
.map(p => (typeof p === "string" ? p : ""))
.filter(Boolean);
return row(a.label || "Address", parts.join(", "));
}),
);
section(
"Online",
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
);
section(
"Anniversary",
anniversaries.map(a => {
const date = a.date;
let formatted = "";
if (typeof date === "object" && date !== null) {
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
else {
const parts: string[] = [];
const pd = date as PartialDate;
if (pd.year) parts.push(String(pd.year));
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
formatted = parts.join("-");
}
} else if (date) {
formatted = String(date);
}
return row(a.kind || "Date", formatted);
}),
);
section("Notes", notes.map(n => row("", n.note || "")));
const photoTag = photoUri
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(name || "Contact")}</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
h1 { margin: 0; font-size: 22px; }
section { margin-top: 20px; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
dt { color: #555; }
dd { margin: 0; word-break: break-word; }
@media print { body { margin: 16mm; } }
</style>
</head>
<body>
<header>
${photoTag}
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
</header>
${rows.join("")}
</body>
</html>`;
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
printWindow.print();
}
@@ -15,6 +15,7 @@ import {
X, X,
AlertCircle, AlertCircle,
ChevronDown, ChevronDown,
ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl'; import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [showCalendarPicker, setShowCalendarPicker] = useState(false); const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>(''); const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown'); const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
const [isCollapsed, setIsCollapsed] = useState(true);
const attachment = findCalendarAttachment(email); const attachment = findCalendarAttachment(email);
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null; const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel'; const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter'; const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const canCollapse = method === 'reply';
const showDetails = !canCollapse || !isCollapsed;
const allowsRsvp = method === 'request'; const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown'; const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)} )}
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span> <span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
</div> </div>
{canCollapse && (
<button
type="button"
onClick={() => setIsCollapsed((prev) => !prev)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : t('collapse')}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
)}
</div> </div>
{/* Content */} {/* Content */}
{showDetails && (
<div className="px-4 py-3 space-y-2.5"> <div className="px-4 py-3 space-y-2.5">
<div className="lg:flex lg:gap-6"> <div className="lg:flex lg:gap-6">
{/* Left: Event info */} {/* Left: Event info */}
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div> </div>
)} )}
</div> </div>
)}
{/* Actions */} {/* Actions */}
{showDetails && (
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap"> <div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
{canRespond && ( {canRespond && (
<> <>
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" /> <Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)} )}
</div> </div>
)}
</div> </div>
); );
} }
+6 -2
View File
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore(); const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
@@ -99,7 +101,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
: "bg-background" : "bg-background"
), ),
selected && !colorTag && "shadow-sm", selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm", !colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110", colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10", isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state // Add visual feedback for checked state
@@ -163,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
email={sender?.email} email={sender?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/> />
)} )}
@@ -302,7 +306,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")} backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
+6 -5
View File
@@ -69,6 +69,7 @@ export function EmailList({
batchMarkAsRead, batchMarkAsRead,
batchDelete, batchDelete,
batchMoveToMailbox, batchMoveToMailbox,
batchArchive,
batchMarkAsSpam, batchMarkAsSpam,
batchUndoSpam, batchUndoSpam,
loadMoreEmails, loadMoreEmails,
@@ -497,12 +498,12 @@ export function EmailList({
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => { onBatchArchive={async () => {
if (!onArchive) return; if (!client) return;
const selected = emails.filter((e) => selectedEmailIds.has(e.id)); try {
for (const email of selected) { await batchArchive(client);
await onArchive(email); } catch (error) {
console.error('Failed to batch archive:', error);
} }
clearSelection();
}} }}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => { onBatchMarkAsSpam={async () => {
+174 -131
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -2322,7 +2322,7 @@ export function EmailViewer({
let blockedExternalContent = false; let blockedExternalContent = false;
// Use shared sanitization config as base (more secure) // Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
// Check if sender is trusted (localStorage list or address book) // Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -2438,7 +2438,7 @@ export function EmailViewer({
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
} }
); );
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (smimeDecryptedText) { if (smimeDecryptedText) {
@@ -2446,7 +2446,7 @@ export function EmailViewer({
} }
// TNEF (winmail.dat) extracted content // TNEF (winmail.dat) extracted content
if (tnefHtml) { if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (tnefText) { if (tnefText) {
@@ -2454,7 +2454,7 @@ export function EmailViewer({
} }
// Embedded message/rfc822 unwrapped content // Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) { if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (embeddedEmailText) { if (embeddedEmailText) {
@@ -2603,7 +2603,7 @@ export function EmailViewer({
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style> <style>
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; } img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; } a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2880,7 +2880,7 @@ export function EmailViewer({
<> <>
{/* Left: Reply actions */} {/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}> <div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && ( {showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -3436,70 +3436,172 @@ export function EmailViewer({
moreMenuOpen ? "translate-x-0" : "translate-x-full" moreMenuOpen ? "translate-x-0" : "translate-x-full"
)}> )}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span> {moreMenuSub ? (
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9"> <button
onClick={() => setMoreMenuSub(null)}
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
<ChevronLeft className="w-5 h-5" />
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
</button>
) : (
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
)}
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
<button {moreMenuSub === null && (
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Archive className="w-5 h-5" />
{t('archive')}
</button>
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
<> <>
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Archive className="w-5 h-5" />
{t('archive')}
</button>
{/* Move to folder (opens sub-view) */}
{moveTree.length > 0 && onMoveToMailbox && (
<button
onClick={() => setMoreMenuSub('move')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<FolderInput className="w-5 h-5" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
<div className="flex -space-x-1 mr-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
})}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isInJunkFolder ? (
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Toggle read state */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
</button>
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
</button>
)}
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div> <button
{(() => { onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
return nodes.map((node) => { >
const Icon = getMoveMailboxIcon(node.role); <Download className="w-5 h-5" />
const isTarget = moveTargetIds.has(node.id); {t('export_email')}
return ( </button>
<div key={node.id}> <button
{isTarget ? ( onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
<button className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }} >
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3" <Upload className="w-5 h-5" />
style={{ paddingLeft: `${1 + depth * 1}rem` }} {t('import_email')}
> </button>
<Icon className="w-5 h-5 flex-shrink-0" /> {onShowShortcuts && (
<span className="truncate">{node.name}</span> <button
</button> onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
) : ( className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
<div >
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground" <Keyboard className="w-5 h-5" />
style={{ paddingLeft: `${1 + depth * 1}rem` }} {t('keyboard_shortcuts')}
> </button>
<Icon className="w-5 h-5 flex-shrink-0" /> )}
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
<div className="h-px bg-border my-1" />
</> </>
)} )}
{/* Tags */} {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
{colorOptions.length > 0 && ( const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<> <>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => { {colorOptions.map((option) => {
const isActive = currentColors.includes(option.value); const isActive = currentColors.includes(option.value);
return ( return (
<button <button
key={option.value} key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn( className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3", "w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium" isActive && "bg-accent font-medium"
@@ -3513,85 +3615,15 @@ export function EmailViewer({
})} })}
{currentColors.length > 0 && ( {currentColors.length > 0 && (
<button <button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }} onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground" className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
> >
<X className="w-4 h-4 flex-shrink-0" /> <X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span> <span>{t('remove_color')}</span>
</button> </button>
)} )}
<div className="h-px bg-border my-1" />
</> </>
)} )}
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isInJunkFolder ? (
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Toggle read state */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
</button>
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
</button>
)}
<div className="h-px bg-border my-1" />
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Download className="w-5 h-5" />
{t('export_email')}
</button>
<button
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Upload className="w-5 h-5" />
{t('import_email')}
</button>
{onShowShortcuts && (
<button
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Keyboard className="w-5 h-5" />
{t('keyboard_shortcuts')}
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -3628,7 +3660,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}> <div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4"> <div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */} {/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && ( {toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -3676,8 +3708,8 @@ export function EmailViewer({
)} )}
</div> </div>
</div> </div>
{/* Date/time on the right of subject row */} {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="flex-shrink-0 text-right"> <div className="hidden sm:block flex-shrink-0 text-right">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap"> <span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span> </span>
@@ -4484,6 +4516,17 @@ export function EmailViewer({
)} )}
</div> </div>
</div> </div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div> </div>
</div> </div>
+12 -4
View File
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView); const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById); const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
@@ -133,7 +135,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "bg-background" : "bg-background"
), ),
selected && !resolvedColorTag && "shadow-sm", selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm", !resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30", isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40", isChecked && "ring-2 ring-primary/20 bg-accent/40",
@@ -181,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
email={sender?.email} email={sender?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/> />
)} )}
@@ -323,7 +327,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")} backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
@@ -358,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
@@ -375,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)).slice(0, 4) )).slice(0, 4)
: participantNames; : participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0]; const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({ const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail, email: latestEmail,
@@ -487,7 +493,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "bg-background" : "bg-background"
), ),
isSelected && !colorTag && "shadow-sm", isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm", !colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110", colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30", hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50", isExpanded && "border-b border-border/50",
@@ -561,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={avatarPerson?.email} email={avatarPerson?.email}
size="md" size="md"
className="flex-shrink-0 shadow-sm" className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/> />
)} )}
@@ -725,7 +733,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */} {/* Hover Quick Actions for thread header */}
<EmailHoverActions <EmailHoverActions
email={latestEmail} email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")} backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
CheckCheck,
MailOpen,
Mails,
Trash2,
FolderPlus,
Pencil,
FolderX,
RefreshCw,
} from "lucide-react";
interface Position {
x: number;
y: number;
}
export type MailboxContextTarget =
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
| { kind: "folders-section" };
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefresh?: () => void;
}
export function MailboxContextMenu({
target,
position,
isOpen,
onClose,
menuRef,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const handleAction = (action: () => void) => {
action();
onClose();
};
if (!target) return null;
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(onCreateFolder!)}
disabled={!onCreateFolder}
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
const mailbox = target.mailbox;
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
destructive
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
+5 -6
View File
@@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link, useRouter } from "@/i18n/navigation"; import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -336,9 +335,9 @@ export function NavigationRail({
); );
})} })}
{/* Admin (Stalwart admins) */} {/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && ( {isStalwartAdmin && (
<NextLink <a
href="/admin" href="/admin"
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
@@ -348,7 +347,7 @@ export function NavigationRail({
> >
<Shield className="w-5 h-5" /> <Shield className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink> </a>
)} )}
{/* Settings */} {/* Settings */}
@@ -514,13 +513,13 @@ export function NavigationRail({
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */} {/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1"> <div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && ( {isStalwartAdmin && (
<NextLink <a
href="/admin" href="/admin"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted" className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title={t("admin") || "Admin"} title={t("admin") || "Admin"}
> >
<Shield className="w-[18px] h-[18px]" /> <Shield className="w-[18px] h-[18px]" />
</NextLink> </a>
)} )}
<Link <Link
+64 -1
View File
@@ -31,6 +31,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store'; import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types'; import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types'; import type { UnifiedMailboxRole } from '@/lib/jmap/types';
@@ -56,6 +58,15 @@ interface SidebarProps {
onCompose?: () => void; onCompose?: () => void;
onSidebarClose?: () => void; onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void; onUnreadFilterClick?: (mailboxId: string) => void;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
className?: string; className?: string;
} }
@@ -187,6 +198,7 @@ interface SidebarRowProps {
dropHandlers?: Record<string, unknown>; dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean; isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean; isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
} }
function SidebarRow({ function SidebarRow({
@@ -206,6 +218,7 @@ function SidebarRow({
dropHandlers, dropHandlers,
isValidDropTarget, isValidDropTarget,
isInvalidDropTarget, isInvalidDropTarget,
onContextMenu,
}: SidebarRowProps) { }: SidebarRowProps) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -213,6 +226,7 @@ function SidebarRow({
return ( return (
<div <div
{...(dropHandlers || {})} {...(dropHandlers || {})}
onContextMenu={onContextMenu}
style={{ paddingBlock: 'var(--density-sidebar-py)' }} style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn( className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150", "group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -365,6 +379,7 @@ function MailboxTreeItem({
isCollapsed, isCollapsed,
onUnreadFilterClick, onUnreadFilterClick,
colorful, colorful,
onContextMenu,
}: { }: {
node: MailboxNode; node: MailboxNode;
selectedMailbox: string; selectedMailbox: string;
@@ -374,6 +389,7 @@ function MailboxTreeItem({
isCollapsed: boolean; isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void; onUnreadFilterClick?: (mailboxId: string) => void;
colorful: boolean; colorful: boolean;
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) { }) {
const tNotifications = useTranslations('notifications'); const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
@@ -423,6 +439,7 @@ function MailboxTreeItem({
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined} dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget} isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget} isInvalidDropTarget={isInvalidDropTarget}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
/> />
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
@@ -436,6 +453,7 @@ function MailboxTreeItem({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick} onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful} colorful={colorful}
onContextMenu={onContextMenu}
/> />
))} ))}
</> </>
@@ -610,6 +628,15 @@ export function Sidebar({
onCompose: _onCompose, onCompose: _onCompose,
onSidebarClose, onSidebarClose,
onUnreadFilterClick, onUnreadFilterClick,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefreshMailboxes,
className, className,
}: SidebarProps) { }: SidebarProps) {
const router = useRouter(); const router = useRouter();
@@ -790,6 +817,23 @@ export function Sidebar({
router.push('/settings'); router.push('/settings');
}; };
const {
contextMenu: mailboxContextMenu,
openContextMenu: openMailboxContextMenu,
closeContextMenu: closeMailboxContextMenu,
menuRef: mailboxMenuRef,
} = useContextMenu<MailboxContextTarget>();
const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => {
const mailbox = mailboxes.find(mb => mb.id === node.id);
if (!mailbox) return;
openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 });
};
const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => {
openMailboxContextMenu(e, { kind: "folders-section" });
};
return ( return (
<div <div
className={cn( className={cn(
@@ -866,7 +910,7 @@ export function Sidebar({
</div> </div>
)} )}
<div> <div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader <SidebarSectionHeader
label={t("folders")} label={t("folders")}
expanded={foldersExpanded} expanded={foldersExpanded}
@@ -894,6 +938,7 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick} onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons} colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/> />
)) ))
)} )}
@@ -934,6 +979,7 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick} onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons} colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/> />
))} ))}
</div> </div>
@@ -975,6 +1021,23 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />} {!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div> </div>
<MailboxContextMenu
target={mailboxContextMenu.data}
position={mailboxContextMenu.position}
isOpen={mailboxContextMenu.isOpen}
onClose={closeMailboxContextMenu}
menuRef={mailboxMenuRef}
onMarkFolderRead={onMarkFolderRead}
onMarkFolderTreeRead={onMarkFolderTreeRead}
onMarkAllFoldersRead={onMarkAllFoldersRead}
onEmptyFolder={onEmptyFolder}
onCreateSubfolder={onCreateSubfolder}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onRefresh={onRefreshMailboxes}
/>
</div> </div>
); );
} }
@@ -7,22 +7,21 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game'; import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() { export function AboutDataSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore(); useSettingsStore();
const { settingsSyncEnabled } = useConfig(); const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false); const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0); const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -86,7 +85,6 @@ export function AdvancedSettings() {
return ( return (
<> <>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />} {showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6"> <div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About"> <button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
@@ -122,88 +120,48 @@ export function AdvancedSettings() {
</div> </div>
</div> </div>
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */} {settingsSyncEnabled && (
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( <SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}> <ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} /> </SettingItem>
</SettingItem> )}
)}
{/* Debug Categories */} {isFeatureEnabled('settingsExportEnabled') && (
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( <SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1"> <Button variant="outline" size="sm" onClick={handleExport}>
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p> {t('export_settings.button')}
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Sender Favicons (Experimental) */}
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Export Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
)}
{/* Import Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button> </Button>
</> </SettingItem>
</SettingItem> )}
)}
{/* Reset Settings */} {isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}> <SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<Button <>
variant={showResetConfirm ? 'destructive' : 'outline'} <input
size="sm" ref={fileInputRef}
onClick={handleReset} type="file"
> accept="application/json,.json"
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')} onChange={handleFileChange}
</Button> className="hidden"
</SettingItem> />
</SettingsSection> <Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button>
</>
</SettingItem>
)}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
</> </>
); );
} }
+11 -72
View File
@@ -2,15 +2,13 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store'; import { useSettingsStore, type Density } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider'; import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react'; import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = { const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -66,16 +64,15 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() { export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour(); const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}> <SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup <RadioGroup
value={theme} value={theme}
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
/> />
</SettingItem> </SettingItem>
{/* Language */}
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
{/* Font Size */}
{!isSettingHidden('fontSize') && ( {!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}> <SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup <RadioGroup
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Density */}
{!isSettingHidden('density') && ( {!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}> <SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup <RadioGroup
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
{/* Toolbar Labels */}
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
{/* Animations */}
{!isSettingHidden('animationsEnabled') && ( {!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}> <SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch <ToggleSwitch
@@ -194,7 +126,14 @@ export function AppearanceSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Restart Tour */} <SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
</SettingItem>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}> <SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button <Button
variant="outline" variant="outline"
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [newKeyword, setNewKeyword] = useState('');
const {
autoSelectReplyIdentity,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting,
} = useSettingsStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
return (
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection>
);
}
+14 -1
View File
@@ -4,11 +4,12 @@ import { useState, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react"; import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section"; import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
export function ContactsSettings() { export function ContactsSettings() {
@@ -20,6 +21,8 @@ export function ContactsSettings() {
supportsSync, supportsSync,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group"); const individuals = contacts.filter(c => c.kind !== "group");
@@ -55,6 +58,16 @@ export function ContactsSettings() {
title={tSettings("title")} title={tSettings("title")}
description={tSettings("description")} description={tSettings("description")}
> >
<SettingItem
label={tSettings("group_by_letter_label")}
description={tSettings("group_by_letter_description")}
>
<ToggleSwitch
checked={groupContactsByLetter}
onChange={(checked) => updateSetting("groupContactsByLetter", checked)}
/>
</SettingItem>
<SettingItem <SettingItem
label={tSettings("import_label")} label={tSettings("import_label")}
description={tSettings("import_description")} description={tSettings("import_description")}
@@ -0,0 +1,81 @@
"use client";
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
export function ContentSendersSettings() {
const t = useTranslations('settings.email_behavior');
const [showTrustedModal, setShowTrustedModal] = useState(false);
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const {
externalContentPolicy,
emailAlwaysLightMode,
trustedSenders,
trustedSendersAddressBook,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
};
return (
<SettingsSection title={t('title')} description={t('description')}>
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
)}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
<ToggleSwitch
checked={emailAlwaysLightMode}
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
/>
</SettingItem>
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
onClick={() => setShowTrustedModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</SettingItem>
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
<TrustedSendersModal
isOpen={showTrustedModal}
onClose={() => setShowTrustedModal(false)}
/>
</SettingsSection>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore, ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { usePolicyStore } from '@/stores/policy-store';
export function DebugSettings() {
const t = useTranslations('settings.advanced');
const { debugMode, debugCategories, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
if (isSettingHidden('debugMode') || !isFeatureEnabled('debugModeEnabled')) {
return (
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
<p className="text-sm text-muted-foreground py-2">{t('debug_mode.description')}</p>
</SettingsSection>
);
}
return (
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
{debugMode && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
</SettingsSection>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem } from './settings-section';
export function LanguageSettings() {
const t = useTranslations('settings.appearance');
return (
<SettingsSection title={t('language.label')} description={t('language.description')}>
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
</SettingsSection>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
{!isSettingHidden('mailLayout') && (
<SettingItem label={tEmail('mail_layout.label')} description={tEmail('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: tEmail('mail_layout.split') },
{ value: 'focus', label: tEmail('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={tEmail} />
</div>
</SettingItem>
)}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -1,118 +1,23 @@
"use client"; "use client";
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store'; import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [ export function ReadingSettings() {
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [showTrustedModal, setShowTrustedModal] = useState(false);
const [isReorganizing, setIsReorganizing] = useState(false); const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null); const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
const [newKeyword, setNewKeyword] = useState('');
const { const {
markAsReadDelay, markAsReadDelay,
deleteAction, deleteAction,
@@ -120,33 +25,17 @@ export function EmailSettings() {
showPreview, showPreview,
mailLayout, mailLayout,
disableThreading, disableThreading,
autoSelectReplyIdentity,
plainTextMode, plainTextMode,
emailsPerPage, emailsPerPage,
externalContentPolicy,
mailAttachmentAction, mailAttachmentAction,
attachmentPosition, attachmentPosition,
emailAlwaysLightMode,
archiveMode, archiveMode,
hoverActions, hoverActions,
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
hideInlineImageAttachments, hideInlineImageAttachments,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
};
const isFocusedLayout = mailLayout === 'focus'; const isFocusedLayout = mailLayout === 'focus';
@@ -163,8 +52,6 @@ export function EmailSettings() {
try { try {
const archiveId = archiveMailbox.originalId || archiveMailbox.id; const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Fetch all emails in the root archive mailbox
const emails = await client.getEmailsInMailbox(archiveId); const emails = await client.getEmailsInMailbox(archiveId);
let movedCount = 0; let movedCount = 0;
@@ -173,10 +60,8 @@ export function EmailSettings() {
const year = emailDate.getFullYear().toString(); const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0'); const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
// Re-read mailboxes from store each iteration in case new ones were created
let currentMailboxes = useEmailStore.getState().mailboxes; let currentMailboxes = useEmailStore.getState().mailboxes;
// Find or create year subfolder
let yearMailbox = currentMailboxes.find( let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId m => m.name === year && m.parentId === archiveId
); );
@@ -190,7 +75,6 @@ export function EmailSettings() {
await client.moveEmail(email.id, yearMailbox.id); await client.moveEmail(email.id, yearMailbox.id);
movedCount++; movedCount++;
} else { } else {
// month mode
const yearId = yearMailbox.originalId || yearMailbox.id; const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = currentMailboxes.find( let monthMailbox = currentMailboxes.find(
m => m.name === month && m.parentId === yearId m => m.name === month && m.parentId === yearId
@@ -215,7 +99,6 @@ export function EmailSettings() {
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
{!isSettingHidden('markAsReadDelay') && ( {!isSettingHidden('markAsReadDelay') && (
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}> <SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
<Select <Select
@@ -231,7 +114,6 @@ export function EmailSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Delete Action */}
{!isSettingHidden('deleteAction') && ( {!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}> <SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -253,7 +135,6 @@ export function EmailSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Archive Mode */}
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}> <SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Select <Select
@@ -287,7 +168,6 @@ export function EmailSettings() {
</div> </div>
</SettingItem> </SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}> <SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch <ToggleSwitch
checked={permanentlyDeleteJunk} checked={permanentlyDeleteJunk}
@@ -295,23 +175,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */}
{!isSettingHidden('showPreview') && ( {!isSettingHidden('showPreview') && (
<SettingItem <SettingItem
label={t('show_preview.label')} label={t('show_preview.label')}
@@ -322,7 +185,6 @@ export function EmailSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Disable Thread Grouping */}
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}> <SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
<ToggleSwitch <ToggleSwitch
checked={disableThreading} checked={disableThreading}
@@ -330,7 +192,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Plain Text Mode */}
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}> <SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch <ToggleSwitch
checked={plainTextMode} checked={plainTextMode}
@@ -338,71 +199,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Hide inline images from attachment list */}
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}> <SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch <ToggleSwitch
checked={hideInlineImageAttachments} checked={hideInlineImageAttachments}
@@ -410,7 +206,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <div className="py-3 border-b border-border space-y-3">
<div> <div>
@@ -443,7 +238,6 @@ export function EmailSettings() {
})} })}
</div> </div>
{/* Hover Actions Display Mode */}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label> <label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -465,7 +259,6 @@ export function EmailSettings() {
</div> </div>
</div> </div>
{/* Corner Selection (only when floating) */}
{hoverActionsMode === 'floating' && ( {hoverActionsMode === 'floating' && (
<div className="pt-1 space-y-2"> <div className="pt-1 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label> <label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
@@ -513,7 +306,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Emails Per Page */}
{!isSettingHidden('emailsPerPage') && ( {!isSettingHidden('emailsPerPage') && (
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}> <SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
<Select <Select
@@ -528,75 +320,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
)} )}
{/* Always Light Mode for Emails */}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
<ToggleSwitch
checked={emailAlwaysLightMode}
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
/>
</SettingItem>
{/* External Content */}
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
)}
{/* Default Mail Program */}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
{/* Trusted Senders */}
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
onClick={() => setShowTrustedModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</SettingItem>
{/* Trusted Senders - address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */}
<TrustedSendersModal
isOpen={showTrustedModal}
onClose={() => setShowTrustedModal(false)}
/>
</SettingsSection> </SettingsSection>
); );
} }
+8 -4
View File
@@ -140,9 +140,11 @@ interface AvatarProps {
contactPhotoUri?: string; contactPhotoUri?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
className?: string; className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean;
} }
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null); const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -222,9 +224,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed const imgSrc = disableImages
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) ? null
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null); : !imgError && !domainFailed
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => { const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source // If the plugin avatar just failed, mark it and fall through to the next source
+16 -3
View File
@@ -45,9 +45,22 @@ export function FlagKR(props: FlagProps) {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" /> <rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" /> <path d="M1.5 0.5 a0.45 0.45 0 1 1 0 0.9 a0.45 0.45 0 1 0 0 -0.9" fill="#CD2E3A" />
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" /> <path d="M1.5 1.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" fill="#0047A0" />
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" /> <circle cx="1.5" cy="0.8" r="0.225" fill="#0047A0" />
<circle cx="1.5" cy="1.2" r="0.225" fill="#CD2E3A" />
<g stroke="#000" strokeWidth="0.06" strokeLinecap="round">
<line x1="0.42" y1="0.35" x2="0.78" y2="0.35" />
<line x1="0.42" y1="0.46" x2="0.78" y2="0.46" />
<line x1="0.42" y1="0.57" x2="0.78" y2="0.57" />
<line x1="2.22" y1="0.35" x2="2.58" y2="0.35" />
<line x1="2.22" y1="0.57" x2="2.58" y2="0.57" />
<line x1="0.42" y1="1.43" x2="0.78" y2="1.43" />
<line x1="0.42" y1="1.65" x2="0.78" y2="1.65" />
<line x1="2.22" y1="1.43" x2="2.58" y2="1.43" />
<line x1="2.22" y1="1.54" x2="2.58" y2="1.54" />
<line x1="2.22" y1="1.65" x2="2.58" y2="1.65" />
</g>
</svg> </svg>
); );
} }
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface PromptDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (value: string) => void;
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function PromptDialog({
isOpen,
onClose,
onSubmit,
title,
message,
placeholder,
defaultValue = "",
confirmText,
cancelText,
}: PromptDialogProps) {
const t = useTranslations("confirm_dialog");
const id = useId();
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setValue(defaultValue);
const t = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, defaultValue]);
useEffect(() => {
if (!isOpen) return;
const handleBackdropClick = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleBackdropClick);
return () => document.removeEventListener("mousedown", handleBackdropClick);
}, [isOpen, onClose, dialogRef]);
if (!isOpen) return null;
const resolvedConfirmText = confirmText || t("confirm");
const resolvedCancelText = cancelText || t("cancel");
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
try {
onSubmit(trimmed);
} finally {
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<form onSubmit={handleSubmit}>
<div className="p-6">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{message && (
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
)}
<Input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="mt-4"
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button type="button" variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{resolvedConfirmText}
</Button>
</div>
</form>
</div>
</div>
);
}
+12 -16
View File
@@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils";
interface UseMailboxDropOptions { interface UseMailboxDropOptions {
mailbox: Mailbox; mailbox: Mailbox;
@@ -30,7 +31,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore(); const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore(); const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target // Determine if this is a valid drop target
@@ -106,31 +107,26 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const emailIds: string[] = JSON.parse(emailIdsJson); const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders) // Move in a single bulk JMAP request (store handles counter updates).
const destinationId = mailbox.originalId || mailbox.id; await moveEmailsToMailbox(client, emailIds, mailbox.id);
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Clear selection if any selected emails were moved // Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) { if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection(); clearSelection();
} }
// Refresh the current mailbox view // Refresh the current mailbox view (honors active search/filters)
await fetchEmails(client, selectedMailbox); await refreshCurrentMailbox(client);
const mailboxPath = getMailboxPath(mailbox, mailboxes);
// Call success callback if provided, otherwise use fallback
if (onSuccess) { if (onSuccess) {
onSuccess(emailIds.length, mailbox.name); onSuccess(emailIds.length, mailboxPath);
} else { } else {
// Fallback for backward compatibility
if (emailIds.length === 1) { if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`); toast.success("Email moved", `Moved to ${mailboxPath}`);
} else { } else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`); toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
} }
} }
@@ -148,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally { } finally {
endDrag(); endDrag();
} }
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]); }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget(); const valid = isValidTarget();
+20 -16
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
// Tailwind v4 breakpoints // Tailwind v4 breakpoints
@@ -12,26 +12,30 @@ const BREAKPOINTS = {
"2xl": 1536, "2xl": 1536,
} as const; } as const;
const getMediaQueryServerSnapshot = () => false;
/** /**
* SSR-safe media query hook * SSR-safe media query hook. On SSR and the first hydration pass we report
* Returns false during SSR to prevent hydration mismatch * `false`; on all subsequent client renders (including client-side navigation
* remounts) we read `matchMedia` synchronously, so components don't flash
* through a one-frame "mobile" layout on desktop.
*/ */
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false); const subscribe = useCallback(
(callback: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
},
[query],
);
useEffect(() => { const getSnapshot = useCallback(
const mediaQuery = window.matchMedia(query); () => window.matchMedia(query).matches,
setMatches(mediaQuery.matches); [query],
);
const handler = (event: MediaQueryListEvent) => { return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
} }
/** /**
+87
View File
@@ -0,0 +1,87 @@
import { useState, useCallback, useRef, useEffect } from "react";
interface PromptDialogState {
isOpen: boolean;
title: string;
message?: string;
placeholder?: string;
defaultValue: string;
confirmText?: string;
cancelText?: string;
onSubmit: (value: string) => void;
}
const INITIAL_STATE: PromptDialogState = {
isOpen: false,
title: "",
defaultValue: "",
onSubmit: () => {},
};
interface PromptOptions {
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function usePromptDialog() {
const [state, setState] = useState<PromptDialogState>(INITIAL_STATE);
const resolveRef = useRef<((value: string | null) => void) | null>(null);
useEffect(() => {
return () => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
};
}, []);
const prompt = useCallback(
(options: PromptOptions): Promise<string | null> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({
isOpen: true,
title: options.title,
message: options.message,
placeholder: options.placeholder,
defaultValue: options.defaultValue ?? "",
confirmText: options.confirmText,
cancelText: options.cancelText,
onSubmit: (value) => {
resolveRef.current = null;
resolve(value);
},
});
});
},
[]
);
const close = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
setState(INITIAL_STATE);
}, []);
return {
dialogProps: {
isOpen: state.isOpen,
onClose: close,
onSubmit: state.onSubmit,
title: state.title,
message: state.message,
placeholder: state.placeholder,
defaultValue: state.defaultValue,
confirmText: state.confirmText,
cancelText: state.cancelText,
},
prompt,
};
}
+63 -22
View File
@@ -1,9 +1,10 @@
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react"; import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
import { format } from "date-fns"; import { format, parseISO } from "date-fns";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
interface DragCreateState { interface DragCreateState {
@@ -12,9 +13,13 @@ interface DragCreateState {
endMinutes: number; endMinutes: number;
} }
export type ResizeEdge = "top" | "bottom";
interface ResizeState { interface ResizeState {
eventId: string; eventId: string;
topPx: number;
heightPx: number; heightPx: number;
startMinutes: number;
durationMinutes: number; durationMinutes: number;
} }
@@ -40,6 +45,7 @@ interface UseTimeGridInteractionsOptions {
created: string; created: string;
error: string; error: string;
}; };
isMobile?: boolean;
} }
export function useTimeGridInteractions({ export function useTimeGridInteractions({
@@ -47,6 +53,7 @@ export function useTimeGridInteractions({
calendars, calendars,
onCreateRange, onCreateRange,
errorMessages, errorMessages,
isMobile,
}: UseTimeGridInteractionsOptions) { }: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => { const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = ((clientY - containerTop) / hourHeight) * 60; const raw = ((clientY - containerTop) / hourHeight) * 60;
@@ -72,6 +79,7 @@ export function useTimeGridInteractions({
dayKey: string, dayKey: string,
dayDate: Date, dayDate: Date,
) => { ) => {
if (isMobile) return;
if (e.button !== 0) return; if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return; if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
@@ -82,7 +90,7 @@ export function useTimeGridInteractions({
dayKey, dayDate, startMinutes: minutes, dayKey, dayDate, startMinutes: minutes,
pointerId: e.pointerId, startY: e.clientY, captured: false, pointerId: e.pointerId, startY: e.clientY, captured: false,
}; };
}, [snapToMinutes]); }, [snapToMinutes, isMobile]);
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => { const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return; if (!dragRef.current) return;
@@ -130,43 +138,65 @@ export function useTimeGridInteractions({
// --- Resize --- // --- Resize ---
const resizeRef = useRef<{ const resizeRef = useRef<{
eventId: string; eventId: string;
edge: ResizeEdge;
startY: number; startY: number;
originalStartMinutes: number;
originalDurationMinutes: number; originalDurationMinutes: number;
originalHeightPx: number;
pointerId: number; pointerId: number;
} | null>(null); } | null>(null);
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null); const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
const computeResize = useCallback((
ref: NonNullable<typeof resizeRef.current>,
clientY: number,
): { startMinutes: number; durationMinutes: number } => {
const deltaY = clientY - ref.startY;
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15;
const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes;
if (ref.edge === "bottom") {
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
}
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
let newStart = ref.originalStartMinutes + deltaMinutes;
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
}, [hourHeight]);
const handleResizePointerDown = useCallback(( const handleResizePointerDown = useCallback((
eventId: string, eventId: string,
edge: ResizeEdge,
originalStartMinutes: number,
originalDurationMinutes: number, originalDurationMinutes: number,
e: PointerEvent, e: PointerEvent,
) => { ) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
resizeRef.current = { resizeRef.current = {
eventId, eventId,
edge,
startY: e.clientY, startY: e.clientY,
originalStartMinutes,
originalDurationMinutes, originalDurationMinutes,
originalHeightPx,
pointerId: e.pointerId, pointerId: e.pointerId,
}; };
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [hourHeight]); }, []);
const handleResizePointerMove = useCallback((e: PointerEvent) => { const handleResizePointerMove = useCallback((e: PointerEvent) => {
if (!resizeRef.current) return; if (!resizeRef.current) return;
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
const deltaY = e.clientY - resizeRef.current.startY; setResizeVisual({
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY); eventId: resizeRef.current.eventId,
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15); topPx: (startMinutes / 60) * hourHeight,
const snappedHeight = (newDurationMinutes / 60) * hourHeight; heightPx: (durationMinutes / 60) * hourHeight,
startMinutes,
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes }); durationMinutes,
}, [hourHeight]); });
}, [hourHeight, computeResize]);
const handleResizePointerUp = useCallback(async (e: PointerEvent) => { const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
const resize = resizeRef.current; const resize = resizeRef.current;
@@ -179,11 +209,11 @@ export function useTimeGridInteractions({
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ } try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
const deltaY = e.clientY - resize.startY; const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
if (newDurationMinutes === resize.originalDurationMinutes) { const startChanged = newStartMinutes !== resize.originalStartMinutes;
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
if (!startChanged && !durationChanged) {
setResizeVisual(null); setResizeVisual(null);
return; return;
} }
@@ -205,14 +235,21 @@ export function useTimeGridInteractions({
try { try {
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId); const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined); const updates: { start?: string; duration: string } = { duration: dur };
if (startChanged && event?.start) {
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
const deltaMinutes = newStartMinutes - resize.originalStartMinutes;
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
}
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
} catch (error) { } catch (error) {
debug.error("Failed to resize event:", resize.eventId, error); debug.error("Failed to resize event:", resize.eventId, error);
toast.error(errorMessages.resize); toast.error(errorMessages.resize);
} finally { } finally {
setResizeVisual(null); setResizeVisual(null);
} }
}, [hourHeight, errorMessages.resize]); }, [computeResize, errorMessages.resize]);
// --- Click / Double-click / Quick-create --- // --- Click / Double-click / Quick-create ---
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -317,14 +354,18 @@ export function useTimeGridInteractions({
const minutes = snapDragMinutes(e); const minutes = snapDragMinutes(e);
const newStart = new Date(day); const newStart = new Date(day);
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0); newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss"); const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
// Emit `start` as a floating wall-clock in the event's own timeZone.
// If we used browser-local, the server would reinterpret it in event.timeZone
// and shift the event by the offset between the two zones.
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
if (newStartISO === data.originalStart) return; if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client; const client = useAuthStore.getState().client;
if (!client) { if (!client) {
toast.error(errorMessages.move); toast.error(errorMessages.move);
return; return;
} }
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined); await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch { } catch {
+38 -3
View File
@@ -219,10 +219,26 @@ export function layoutOverlappingEvents(
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes); return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
}); });
const columns: { event: CalendarEvent; end: number }[][] = [];
const result: TimedEventLayout[] = []; const result: TimedEventLayout[] = [];
let columns: { event: CalendarEvent; end: number }[][] = [];
let clusterStart = 0;
let clusterMaxEnd = 0;
const flushCluster = () => {
const total = columns.length;
for (let i = clusterStart; i < result.length; i++) {
result[i].totalColumns = total;
}
};
for (const event of sorted) { for (const event of sorted) {
if (columns.length > 0 && event.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false; let placed = false;
for (let col = 0; col < columns.length; col++) { for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= event.startMinutes)) { if (columns[col].every(e => e.end <= event.startMinutes)) {
@@ -236,10 +252,10 @@ export function layoutOverlappingEvents(
columns.push([{ event: event.event, end: event.endMinutes }]); columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ ...event, column: columns.length - 1, totalColumns: 0 }); result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
} }
clusterMaxEnd = Math.max(clusterMaxEnd, event.endMinutes);
} }
const total = columns.length; flushCluster();
result.forEach(r => r.totalColumns = total);
return result; return result;
} }
@@ -256,3 +272,22 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined { export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
return Object.keys(event.calendarIds || {})[0]; return Object.keys(event.calendarIds || {})[0];
} }
export function formatIsoInTimeZone(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
const hour = map.hour === "24" ? "00" : map.hour;
return `${map.year}-${map.month}-${map.day}T${hour}:${map.minute}:${map.second}`;
}
+56
View File
@@ -269,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId);
return;
}
for (const { id, receivedAt } of emails) {
const email = this.data.emails.find(e => e.id === id);
if (!email) continue;
const d = new Date(receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId);
if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId);
let destId = yearBox.id;
if (mode === 'month') {
let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id);
if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id);
destId = monthBox.id;
}
email.mailboxIds = { [destId]: true };
}
this.recalcMailboxCounts();
}
async moveEmail(emailId: string, toMailboxId: string): Promise<void> { async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId); const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [toMailboxId]: true }; if (email) email.mailboxIds = { [toMailboxId]: true };
@@ -283,6 +312,33 @@ export class DemoJMAPClient implements IJMAPClient {
return removed; return removed;
} }
async markMailboxAsRead(mailboxId: string): Promise<number> {
let count = 0;
for (const email of this.data.emails) {
if (email.mailboxIds[mailboxId] && email.keywords.$seen !== true) {
email.keywords.$seen = true;
count++;
}
}
this.recalcMailboxCounts();
return count;
}
async markAllAsRead(excludeMailboxIds: string[] = []): Promise<number> {
const excluded = new Set(excludeMailboxIds);
let count = 0;
for (const email of this.data.emails) {
if (email.keywords.$seen === true) continue;
const mbIds = Object.keys(email.mailboxIds);
const onlyInExcluded = mbIds.length > 0 && mbIds.every(id => excluded.has(id));
if (onlyInExcluded) continue;
email.keywords.$seen = true;
count++;
}
this.recalcMailboxCounts();
return count;
}
async markAsSpam(emailId: string): Promise<void> { async markAsSpam(emailId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId); const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk'); const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
+20
View File
@@ -36,6 +36,26 @@ export function sanitizeEmailHtml(html: string): string {
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG); return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
} }
/**
* Sanitize config for emails rendered inside a sandboxed iframe.
* Allows <style> tags because CSS is scoped to the iframe document and
* cannot leak into the host app. Scripts are still blocked by the sandbox
* attribute (no allow-scripts). Use ONLY for iframe-rendered content
* never for content rendered into the main DOM.
*/
export const EMAIL_IFRAME_SANITIZE_CONFIG = {
...EMAIL_SANITIZE_CONFIG,
FORBID_TAGS: EMAIL_SANITIZE_CONFIG.FORBID_TAGS.filter((t) => t !== 'style'),
};
/**
* Sanitize email HTML for rendering inside a sandboxed iframe.
* Preserves <style> tags so the email's own CSS is applied.
*/
export function sanitizeEmailHtmlForIframe(html: string): string {
return DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
}
/** /**
* Sanitize HTML signature with stricter rules * Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources * Only allows basic formatting, no external resources
+9
View File
@@ -85,8 +85,17 @@ export interface IJMAPClient {
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>; batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
): Promise<void>;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>; moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>; emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>; markAsSpam(emailId: string, accountId?: string): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>; undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
+207 -1
View File
@@ -1207,6 +1207,113 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
): Promise<void> {
if (emails.length === 0) return;
const targetAccountId = accountId || this.accountId;
if (mode === 'single') {
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId);
return;
}
type Dest = { year: string; month?: string };
const destFor = new Map<string, Dest>();
for (const e of emails) {
const d = new Date(e.receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
}
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
const yearIdFor = new Map<string, string>();
const monthIdFor = new Map<string, string>();
const createEntries: Record<string, Record<string, unknown>> = {};
const findExisting = (name: string, parentId: string) =>
existingMailboxes.find(m =>
m.accountId === targetAccountId &&
m.name === name &&
(m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)),
);
for (const dest of destFor.values()) {
if (!yearIdFor.has(dest.year)) {
const existing = findExisting(dest.year, archiveMailboxId);
if (existing) {
yearIdFor.set(dest.year, existing.originalId || existing.id);
} else {
const cid = `year-${dest.year}`;
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
yearIdFor.set(dest.year, `#${cid}`);
}
}
if (mode === 'month' && dest.month) {
const monthKey = `${dest.year}/${dest.month}`;
if (!monthIdFor.has(monthKey)) {
const yearRef = yearIdFor.get(dest.year)!;
// Only look up existing month folders under real (non-creation-ref) year ids.
const existingMonth = yearRef.startsWith('#')
? undefined
: findExisting(dest.month, yearRef);
if (existingMonth) {
monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id);
} else {
const cid = `month-${dest.year}-${dest.month}`;
createEntries[cid] = { name: dest.month, parentId: yearRef };
monthIdFor.set(monthKey, `#${cid}`);
}
}
}
}
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const [emailId, dest] of destFor.entries()) {
const destId = mode === 'month' && dest.month
? monthIdFor.get(`${dest.year}/${dest.month}`)!
: yearIdFor.get(dest.year)!;
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
const response = await this.request(methodCalls);
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' ')}`);
}
}
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
}
}
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> { async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
const response = await this.request([ const response = await this.request([
@@ -1254,6 +1361,99 @@ export class JMAPClient implements IJMAPClient {
return totalDestroyed; return totalDestroyed;
} }
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
let totalMarked = 0;
let hasMore = true;
while (hasMore) {
const queryResponse = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: {
operator: "AND",
conditions: [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
],
},
limit: 500,
}, "0"],
]);
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
if (ids.length === 0) break;
const updates = Object.fromEntries(
ids.map((id) => [id, { "keywords/$seen": true }])
);
await this.request([
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
]);
totalMarked += ids.length;
hasMore = ids.length === 500;
}
return totalMarked;
}
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const excludeSet = new Set(excludeMailboxIds);
let totalMarked = 0;
let hasMore = true;
let position = 0;
while (hasMore) {
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: { notKeyword: "$seen" },
limit: 500,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["id", "mailboxIds"],
}, "1"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
const getResult = response.methodResponses?.[1]?.[1];
const ids: string[] = queryResult?.ids || [];
const emails: Array<{ id: string; mailboxIds?: Record<string, boolean> }> = getResult?.list || [];
if (ids.length === 0) break;
const targetIds = excludeSet.size === 0
? ids
: emails
.filter(e => {
const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : [];
return mbIds.some(id => !excludeSet.has(id));
})
.map(e => e.id);
if (targetIds.length > 0) {
const updates = Object.fromEntries(
targetIds.map((id) => [id, { "keywords/$seen": true }])
);
await this.request([
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
]);
totalMarked += targetIds.length;
}
hasMore = ids.length === 500;
position += ids.length;
}
return totalMarked;
}
async markAsSpam(emailId: string, accountId?: string): Promise<void> { async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
@@ -1316,7 +1516,13 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1]; const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.[createId]) { if (result?.notCreated?.[createId]) {
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`); const err = result.notCreated[createId];
const details = [err.type || 'unknown error'];
if (Array.isArray(err.properties) && err.properties.length > 0) {
details.push(`properties=[${err.properties.join(', ')}]`);
}
if (err.description) details.push(err.description);
throw new Error(`Failed to create mailbox: ${details.join(' ')}`);
} }
const created = result?.created?.[createId]; const created = result?.created?.[createId];
+13 -7
View File
@@ -2,18 +2,23 @@ import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery'; import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; function getClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
}
export function getRequiredConfig() { export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID; const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = process.env.OAUTH_ISSUER_URL; const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
if (!clientId || !serverUrl) { if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`); throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
} }
const discoveryUrl = issuerUrl?.trim() || serverUrl; const discoveryUrl = issuerUrl?.trim() || serverUrl;
if (issuerUrl !== undefined && !issuerUrl.trim()) { if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery'); logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
} }
return { clientId, serverUrl, discoveryUrl }; return { clientId, serverUrl, discoveryUrl };
@@ -36,8 +41,9 @@ export async function getMetadata(): Promise<OAuthMetadata | null> {
export function buildOAuthParams(base: Record<string, string>): URLSearchParams { export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig(); const { clientId } = getRequiredConfig();
const params = new URLSearchParams({ ...base, client_id: clientId }); const params = new URLSearchParams({ ...base, client_id: clientId });
if (CLIENT_SECRET) { const secret = getClientSecret();
params.set('client_secret', CLIENT_SECRET); if (secret) {
params.set('client_secret', secret);
} }
return params; return params;
} }
+19
View File
@@ -430,3 +430,22 @@ export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
traverse(nodes); traverse(nodes);
return result; return result;
} }
export function getMailboxPath(
mailbox: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
allMailboxes: Array<Pick<Mailbox, 'id' | 'name' | 'parentId'>>,
separator = ' ',
): string {
const byId = new Map(allMailboxes.map(m => [m.id, m]));
const names: string[] = [mailbox.name];
const visited = new Set<string>([mailbox.id]);
let parentId = mailbox.parentId;
while (parentId && !visited.has(parentId)) {
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) break;
names.unshift(parent.name);
parentId = parent.parentId;
}
return names.join(separator);
}
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Dateien", "files": "Dateien",
"contacts": "Kontakte", "contacts": "Kontakte",
"sidebar_apps": "Sidebar-Apps", "sidebar_apps": "Sidebar-Apps",
"notifications": "Benachrichtigungen" "notifications": "Benachrichtigungen",
"layout": "Layout",
"reading": "Lesen",
"composing": "Verfassen",
"content_senders": "Inhalte & Absender",
"about_data": "Über & Daten",
"debug": "Debug"
}, },
"tab_groups": { "tab_groups": {
"general": "Allgemein", "general": "Allgemein",
"account": "Konto & Identität", "account": "Konto & Identität",
"organization": "E-Mail-Organisation", "organization": "E-Mail-Organisation",
"apps": "Apps", "apps": "Apps",
"system": "System" "system": "System",
"appearance": "Darstellung",
"mail": "E-Mail",
"privacy": "Datenschutz & Sicherheit",
"advanced": "Erweitert"
}, },
"appearance": { "appearance": {
"title": "Darstellung", "title": "Darstellung",
@@ -1275,6 +1285,10 @@
"label": "Absender-Favicons (Experimentell)", "label": "Absender-Favicons (Experimentell)",
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen" "description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
}, },
"show_avatars_in_junk": {
"label": "Avatare im Spam-Ordner anzeigen",
"description": "Profilbilder und Favicons für Absender im Spam-Ordner anzeigen. Standardmäßig deaktiviert, damit Phishing-Versuche nicht durch vertraute Logos legitim wirken."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Tastaturkürzel", "label": "Tastaturkürzel",
"description": "Verfügbare Tastaturkürzel anzeigen", "description": "Verfügbare Tastaturkürzel anzeigen",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} E-Mails ausgewählt", "items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten" "edit_draft": "Entwurf bearbeiten"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
"mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
"mark_all_folders_read": "Alle Ordner als gelesen markieren",
"new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...",
"rename": "Umbenennen...",
"empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen",
"refresh": "Aktualisieren",
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
"delete_confirm_title": "Ordner löschen",
"delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
"prompt_new_subfolder": "Gib einen Namen für den neuen Unterordner ein.",
"prompt_new_folder": "Gib einen Namen für den neuen Ordner ein.",
"prompt_rename": "Gib einen neuen Namen für diesen Ordner ein.",
"placeholder_folder_name": "Ordnername",
"create": "Erstellen",
"rename_confirm": "Umbenennen",
"toast_marked_read": "Ordner als gelesen markiert",
"toast_marked_read_count": "{count, plural, one {1 Nachricht} other {# Nachrichten}} als gelesen markiert",
"toast_already_read": "Keine ungelesenen Nachrichten",
"toast_marked_all_read": "Alle Ordner als gelesen markiert",
"toast_emptied": "Ordner geleert",
"toast_folder_created": "Ordner erstellt",
"toast_folder_renamed": "Ordner umbenannt",
"toast_folder_deleted": "Ordner gelöscht",
"toast_error_mark_read": "Konnte nicht als gelesen markiert werden",
"toast_error_empty": "Ordner konnte nicht geleert werden",
"toast_error_create": "Ordner konnte nicht erstellt werden",
"toast_error_rename": "Ordner konnte nicht umbenannt werden",
"toast_error_delete": "Ordner konnte nicht gelöscht werden",
"toast_error_delete_has_children": "Ordner enthält Unterordner. Entferne diese zuerst.",
"toast_error_delete_has_email": "Ordner ist nicht leer. Leere ihn zuerst."
},
"shortcuts": { "shortcuts": {
"title": "Tastaturkürzel", "title": "Tastaturkürzel",
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen", "tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei", "empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_search": "Keine Kontakte gefunden", "empty_search": "Keine Kontakte gefunden",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff", "empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
"clear_search": "Suche löschen", "clear_search": "Suche löschen",
"import_vcard": "vCard importieren", "import_vcard": "vCard importieren",
"delete_confirm_title": "Kontakt löschen", "delete_confirm_title": "Kontakt löschen",
@@ -1814,7 +1867,22 @@
"import_to_smime": "In S/MIME importieren", "import_to_smime": "In S/MIME importieren",
"cert_already_imported": "Zertifikat bereits importiert", "cert_already_imported": "Zertifikat bereits importiert",
"cert_imported": "Zertifikat importiert", "cert_imported": "Zertifikat importiert",
"cert_import_failed": "Import des Zertifikats fehlgeschlagen" "cert_import_failed": "Import des Zertifikats fehlgeschlagen",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Weitere Aktionen",
"age_years": "{count, plural, one {1 Jahr alt} other {# Jahre alt}}",
"years_since": "{count, plural, one {1 Jahr} other {# Jahre}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Neuer Kontakt", "create_title": "Neuer Kontakt",
@@ -1919,7 +1989,13 @@
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format", "email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden", "save_failed": "Kontakt konnte nicht gespeichert werden",
"delete": "Löschen" "delete": "Löschen",
"upload_photo": "Foto hochladen",
"remove_photo": "Foto entfernen",
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
"photo_invalid": "Ungültige Bilddatei",
"change_photo": "Ändern"
}, },
"groups": { "groups": {
"create": "Neue Gruppe", "create": "Neue Gruppe",
@@ -1979,6 +2055,37 @@
"error_create": "Kontakt konnte nicht erstellt werden", "error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden", "error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden" "error_delete": "Kontakt konnte nicht gelöscht werden"
},
"context_menu": {
"open": "Öffnen",
"edit": "Bearbeiten",
"send_email": "E-Mail senden",
"add_to_group": "Zur Gruppe hinzufügen",
"export_vcard": "Als vCard exportieren",
"delete": "Löschen",
"call": "Anrufen",
"duplicate": "Duplizieren",
"print": "Drucken"
},
"filters": {
"toggle": "Filter",
"select": "Auswählen",
"clear": "Zurücksetzen",
"close": "Schließen",
"title": "Erweiterte Filter",
"organization": "Firma",
"organization_placeholder": "z. B. Acme GmbH",
"job_title": "Berufsbezeichnung",
"job_title_placeholder": "z. B. Designer",
"location": "Ort",
"location_placeholder": "Stadt oder Land",
"email_domain": "E-Mail-Domain",
"email_domain_placeholder": "beispiel.de",
"birthday_month": "Geburtstag im",
"any_month": "Beliebiger Monat",
"has_email": "Mit E-Mail",
"has_phone": "Mit Telefon",
"has_photo": "Mit Foto"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Vorläufig", "tentative": "Vorläufig",
"needs_action": "Antwort ausstehend", "needs_action": "Antwort ausstehend",
"remove": "Entfernen", "remove": "Entfernen",
"edit": "Bearbeiten",
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen", "email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
"send_invitations": "Einladungen an Teilnehmer senden", "send_invitations": "Einladungen an Teilnehmer senden",
"status_summary": "{accepted} zugesagt, {pending} ausstehend", "status_summary": "{accepted} zugesagt, {pending} ausstehend",
+111 -7
View File
@@ -426,7 +426,9 @@
"cancel_info": "The organizer has cancelled this event.", "cancel_info": "The organizer has cancelled this event.",
"event_updated": "Update #{sequence}", "event_updated": "Update #{sequence}",
"event_status_tentative": "Tentative", "event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled" "event_status_cancelled": "Cancelled",
"expand": "Show details",
"collapse": "Hide details"
}, },
"send": "Send", "send": "Send",
"more": "more" "more": "more"
@@ -671,14 +673,24 @@
"contacts": "Contacts", "contacts": "Contacts",
"encryption": "Encryption", "encryption": "Encryption",
"sidebar_apps": "Sidebar Apps", "sidebar_apps": "Sidebar Apps",
"notifications": "Notifications" "notifications": "Notifications",
"layout": "Layout",
"reading": "Reading",
"composing": "Composing",
"content_senders": "Content & Senders",
"about_data": "About & Data",
"debug": "Debug"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
"account": "Account & Identity", "account": "Account & Identity",
"organization": "Mail Organization", "organization": "Mail Organization",
"apps": "Apps", "apps": "Apps",
"system": "System" "system": "System",
"appearance": "Appearance",
"mail": "Mail",
"privacy": "Privacy & Security",
"advanced": "Advanced"
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
@@ -1275,6 +1287,10 @@
"label": "Sender Favicons (Experimental)", "label": "Sender Favicons (Experimental)",
"description": "Show website icons as profile pictures for business senders" "description": "Show website icons as profile pictures for business senders"
}, },
"show_avatars_in_junk": {
"label": "Show Avatars in Junk Folder",
"description": "Show profile images and favicons for senders in the junk folder. Disabled by default to avoid lending visual legitimacy to phishing attempts."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Keyboard Shortcuts", "label": "Keyboard Shortcuts",
"description": "View available keyboard shortcuts", "description": "View available keyboard shortcuts",
@@ -1310,6 +1326,8 @@
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
"group_by_letter_label": "Group by first letter",
"group_by_letter_description": "Show alphabetical section headers in the contact list",
"import_label": "Import Contacts", "import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file", "import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts", "export_label": "Export Contacts",
@@ -1576,6 +1594,43 @@
"items_selected": "{count} emails selected", "items_selected": "{count} emails selected",
"edit_draft": "Edit Draft" "edit_draft": "Edit Draft"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first."
},
"shortcuts": { "shortcuts": {
"title": "Keyboard Shortcuts", "title": "Keyboard Shortcuts",
"tip": "Press ? anytime to show this help", "tip": "Press ? anytime to show this help",
@@ -1737,6 +1792,8 @@
"empty_state_subtitle": "Create your first contact or import from a vCard file", "empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_search": "No contacts match your search", "empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term", "empty_search_hint": "Try a different search term",
"empty_filtered": "No contacts match your filters",
"empty_filtered_hint": "Try adjusting or clearing filters",
"clear_search": "Clear search", "clear_search": "Clear search",
"import_vcard": "Import vCard", "import_vcard": "Import vCard",
"delete_confirm_title": "Delete contact", "delete_confirm_title": "Delete contact",
@@ -1814,7 +1871,22 @@
"calendar": "Calendar", "calendar": "Calendar",
"calendar_uri": "Calendar URL", "calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL", "scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL" "freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "More actions",
"age_years": "{count, plural, one {1 year old} other {# years old}}",
"years_since": "{count, plural, one {1 year} other {# years}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1895,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "New Contact", "create_title": "New Contact",
@@ -1919,7 +1993,13 @@
"email_invalid": "Please enter a valid email address", "email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format", "email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact", "save_failed": "Failed to save contact",
"delete": "Delete" "delete": "Delete",
"upload_photo": "Upload photo",
"remove_photo": "Remove photo",
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
"photo_too_large": "Image is too large (max 10 MB)",
"photo_invalid": "Invalid image file",
"change_photo": "Change"
}, },
"groups": { "groups": {
"create": "New Group", "create": "New Group",
@@ -1986,7 +2066,30 @@
"send_email": "Send email", "send_email": "Send email",
"add_to_group": "Add to group", "add_to_group": "Add to group",
"export_vcard": "Export as vCard", "export_vcard": "Export as vCard",
"delete": "Delete" "delete": "Delete",
"call": "Call",
"duplicate": "Duplicate",
"print": "Print"
},
"filters": {
"toggle": "Filters",
"select": "Select",
"clear": "Clear",
"close": "Close",
"title": "Advanced filters",
"organization": "Company",
"organization_placeholder": "e.g. Acme Corp",
"job_title": "Job title",
"job_title_placeholder": "e.g. Designer",
"location": "Location",
"location_placeholder": "City or country",
"email_domain": "Email domain",
"email_domain_placeholder": "example.com",
"birthday_month": "Birthday in",
"any_month": "Any month",
"has_email": "Has email",
"has_phone": "Has phone",
"has_photo": "Has photo"
} }
}, },
"calendar": { "calendar": {
@@ -2062,6 +2165,7 @@
"tentative": "Tentative", "tentative": "Tentative",
"needs_action": "Needs action", "needs_action": "Needs action",
"remove": "Remove", "remove": "Remove",
"edit": "Edit",
"email_placeholder": "Add email address or search contacts", "email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants", "send_invitations": "Send invitations to participants",
"status_summary": "{accepted} accepted, {pending} pending", "status_summary": "{accepted} accepted, {pending} pending",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Archivos", "files": "Archivos",
"contacts": "Contactos", "contacts": "Contactos",
"sidebar_apps": "Apps de barra lateral", "sidebar_apps": "Apps de barra lateral",
"notifications": "Notificaciones" "notifications": "Notificaciones",
"layout": "Diseño",
"reading": "Lectura",
"composing": "Redacción",
"content_senders": "Contenido y remitentes",
"about_data": "Acerca de y datos",
"debug": "Depuración"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
"account": "Cuenta e identidad", "account": "Cuenta e identidad",
"organization": "Organización del correo", "organization": "Organización del correo",
"apps": "Aplicaciones", "apps": "Aplicaciones",
"system": "Sistema" "system": "Sistema",
"appearance": "Apariencia",
"mail": "Correo",
"privacy": "Privacidad y seguridad",
"advanced": "Avanzado"
}, },
"appearance": { "appearance": {
"title": "Apariencia", "title": "Apariencia",
@@ -1275,6 +1285,10 @@
"label": "Favicons de remitente (Experimental)", "label": "Favicons de remitente (Experimental)",
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales" "description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
}, },
"show_avatars_in_junk": {
"label": "Mostrar avatares en la carpeta de spam",
"description": "Mostrar imágenes de perfil y favicons de remitentes en la carpeta de spam. Desactivado por defecto para no dar apariencia legítima a los intentos de phishing."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Atajos de Teclado", "label": "Atajos de Teclado",
"description": "Ver atajos de teclado disponibles", "description": "Ver atajos de teclado disponibles",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} correos seleccionados", "items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador" "edit_draft": "Editar borrador"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Atajos de Teclado", "title": "Atajos de Teclado",
"tip": "Presione ? en cualquier momento para mostrar esta ayuda", "tip": "Presione ? en cualquier momento para mostrar esta ayuda",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard", "empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
"empty_search": "Ningún contacto coincide con tu búsqueda", "empty_search": "Ningún contacto coincide con tu búsqueda",
"empty_search_hint": "Prueba con otro término de búsqueda", "empty_search_hint": "Prueba con otro término de búsqueda",
"empty_filtered": "Ningún contacto coincide con tus filtros",
"empty_filtered_hint": "Ajusta o limpia los filtros",
"clear_search": "Borrar búsqueda", "clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard", "import_vcard": "Importar vCard",
"delete_confirm_title": "Eliminar contacto", "delete_confirm_title": "Eliminar contacto",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importar a S/MIME", "import_to_smime": "Importar a S/MIME",
"cert_already_imported": "Certificado ya importado", "cert_already_imported": "Certificado ya importado",
"cert_imported": "Certificado importado", "cert_imported": "Certificado importado",
"cert_import_failed": "Error al importar el certificado" "cert_import_failed": "Error al importar el certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Más acciones",
"age_years": "{count, plural, one {1 año} other {# años}}",
"years_since": "{count, plural, one {hace 1 año} other {hace # años}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nuevo contacto", "create_title": "Nuevo contacto",
@@ -1919,7 +1989,13 @@
"email_invalid": "Introduce una dirección de correo válida", "email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido", "email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto", "save_failed": "Error al guardar el contacto",
"delete": "Eliminar" "delete": "Eliminar",
"upload_photo": "Subir foto",
"remove_photo": "Quitar foto",
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
"photo_invalid": "Archivo de imagen no válido",
"change_photo": "Cambiar"
}, },
"groups": { "groups": {
"create": "Nuevo grupo", "create": "Nuevo grupo",
@@ -1979,6 +2055,37 @@
"error_create": "Error al crear el contacto", "error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto", "error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto" "error_delete": "Error al eliminar el contacto"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar correo",
"add_to_group": "Añadir al grupo",
"export_vcard": "Exportar como vCard",
"delete": "Eliminar",
"call": "Llamar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Seleccionar",
"clear": "Borrar",
"close": "Cerrar",
"title": "Filtros avanzados",
"organization": "Empresa",
"organization_placeholder": "p. ej. Acme",
"job_title": "Puesto",
"job_title_placeholder": "p. ej. Diseñador",
"location": "Ubicación",
"location_placeholder": "Ciudad o país",
"email_domain": "Dominio de correo",
"email_domain_placeholder": "ejemplo.com",
"birthday_month": "Cumpleaños en",
"any_month": "Cualquier mes",
"has_email": "Con correo",
"has_phone": "Con teléfono",
"has_photo": "Con foto"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provisional", "tentative": "Provisional",
"needs_action": "Pendiente de respuesta", "needs_action": "Pendiente de respuesta",
"remove": "Eliminar", "remove": "Eliminar",
"edit": "Editar",
"email_placeholder": "Añadir dirección de correo o buscar contactos", "email_placeholder": "Añadir dirección de correo o buscar contactos",
"send_invitations": "Enviar invitaciones a los participantes", "send_invitations": "Enviar invitaciones a los participantes",
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)", "status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Fichiers", "files": "Fichiers",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale", "sidebar_apps": "Apps de la barre latérale",
"notifications": "Notifications" "notifications": "Notifications",
"layout": "Mise en page",
"reading": "Lecture",
"composing": "Rédaction",
"content_senders": "Contenu et expéditeurs",
"about_data": "À propos et données",
"debug": "Débogage"
}, },
"tab_groups": { "tab_groups": {
"general": "Général", "general": "Général",
"account": "Compte & Identité", "account": "Compte & Identité",
"organization": "Organisation des e-mails", "organization": "Organisation des e-mails",
"apps": "Applications", "apps": "Applications",
"system": "Système" "system": "Système",
"appearance": "Apparence",
"mail": "Courrier",
"privacy": "Confidentialité et sécurité",
"advanced": "Avancé"
}, },
"appearance": { "appearance": {
"title": "Apparence", "title": "Apparence",
@@ -1275,6 +1285,10 @@
"label": "Favicons des expéditeurs (Expérimental)", "label": "Favicons des expéditeurs (Expérimental)",
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels" "description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
}, },
"show_avatars_in_junk": {
"label": "Afficher les avatars dans le dossier indésirable",
"description": "Afficher les photos de profil et favicons des expéditeurs dans le dossier indésirable. Désactivé par défaut pour éviter de donner une apparence légitime aux tentatives d'hameçonnage."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Raccourcis clavier", "label": "Raccourcis clavier",
"description": "Voir les raccourcis clavier disponibles", "description": "Voir les raccourcis clavier disponibles",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} emails sélectionnés", "items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon" "edit_draft": "Modifier le brouillon"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Raccourcis clavier", "title": "Raccourcis clavier",
"tip": "Appuyez sur ? à tout moment pour afficher cette aide", "tip": "Appuyez sur ? à tout moment pour afficher cette aide",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard", "empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_search": "Aucun contact ne correspond à votre recherche", "empty_search": "Aucun contact ne correspond à votre recherche",
"empty_search_hint": "Essayez un autre terme de recherche", "empty_search_hint": "Essayez un autre terme de recherche",
"empty_filtered": "Aucun contact ne correspond à vos filtres",
"empty_filtered_hint": "Ajustez ou effacez les filtres",
"clear_search": "Effacer la recherche", "clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard", "import_vcard": "Importer vCard",
"delete_confirm_title": "Supprimer le contact", "delete_confirm_title": "Supprimer le contact",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importer dans S/MIME", "import_to_smime": "Importer dans S/MIME",
"cert_already_imported": "Certificat déjà importé", "cert_already_imported": "Certificat déjà importé",
"cert_imported": "Certificat importé", "cert_imported": "Certificat importé",
"cert_import_failed": "Échec de l'import du certificat" "cert_import_failed": "Échec de l'import du certificat",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Plus d'actions",
"age_years": "{count, plural, one {1 an} other {# ans}}",
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nouveau contact", "create_title": "Nouveau contact",
@@ -1919,7 +1989,13 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide", "email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide", "email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact", "save_failed": "Échec de l'enregistrement du contact",
"delete": "Supprimer" "delete": "Supprimer",
"upload_photo": "Téléverser une photo",
"remove_photo": "Supprimer la photo",
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
"photo_too_large": "Limage est trop grande (max 10 Mo)",
"photo_invalid": "Fichier image invalide",
"change_photo": "Modifier"
}, },
"groups": { "groups": {
"create": "Nouveau groupe", "create": "Nouveau groupe",
@@ -1979,6 +2055,37 @@
"error_create": "Échec de la création du contact", "error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact", "error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact" "error_delete": "Échec de la suppression du contact"
},
"context_menu": {
"open": "Ouvrir",
"edit": "Modifier",
"send_email": "Envoyer un courriel",
"add_to_group": "Ajouter au groupe",
"export_vcard": "Exporter en vCard",
"delete": "Supprimer",
"call": "Appeler",
"duplicate": "Dupliquer",
"print": "Imprimer"
},
"filters": {
"toggle": "Filtres",
"select": "Sélectionner",
"clear": "Effacer",
"close": "Fermer",
"title": "Filtres avancés",
"organization": "Entreprise",
"organization_placeholder": "ex. Acme",
"job_title": "Poste",
"job_title_placeholder": "ex. Designer",
"location": "Lieu",
"location_placeholder": "Ville ou pays",
"email_domain": "Domaine e-mail",
"email_domain_placeholder": "exemple.com",
"birthday_month": "Anniversaire en",
"any_month": "Tous les mois",
"has_email": "Avec e-mail",
"has_phone": "Avec téléphone",
"has_photo": "Avec photo"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provisoire", "tentative": "Provisoire",
"needs_action": "En attente de réponse", "needs_action": "En attente de réponse",
"remove": "Retirer", "remove": "Retirer",
"edit": "Modifier",
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts", "email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
"send_invitations": "Envoyer les invitations aux participants", "send_invitations": "Envoyer les invitations aux participants",
"status_summary": "{accepted} accepté(s), {pending} en attente", "status_summary": "{accepted} accepté(s), {pending} en attente",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "File", "files": "File",
"contacts": "Contatti", "contacts": "Contatti",
"sidebar_apps": "App nella barra laterale", "sidebar_apps": "App nella barra laterale",
"notifications": "Notifiche" "notifications": "Notifiche",
"layout": "Layout",
"reading": "Lettura",
"composing": "Composizione",
"content_senders": "Contenuto e mittenti",
"about_data": "Informazioni e dati",
"debug": "Debug"
}, },
"tab_groups": { "tab_groups": {
"general": "Generale", "general": "Generale",
"account": "Account e identità", "account": "Account e identità",
"organization": "Organizzazione e-mail", "organization": "Organizzazione e-mail",
"apps": "Applicazioni", "apps": "Applicazioni",
"system": "Sistema" "system": "Sistema",
"appearance": "Aspetto",
"mail": "Posta",
"privacy": "Privacy e sicurezza",
"advanced": "Avanzate"
}, },
"appearance": { "appearance": {
"title": "Aspetto", "title": "Aspetto",
@@ -1275,6 +1285,10 @@
"label": "Favicon dei mittenti (Sperimentale)", "label": "Favicon dei mittenti (Sperimentale)",
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali" "description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
}, },
"show_avatars_in_junk": {
"label": "Mostra avatar nella cartella spam",
"description": "Mostra immagini profilo e favicon dei mittenti nella cartella spam. Disattivato per impostazione predefinita per non dare apparenza legittima ai tentativi di phishing."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Scorciatoie da tastiera", "label": "Scorciatoie da tastiera",
"description": "Visualizza le scorciatoie da tastiera disponibili", "description": "Visualizza le scorciatoie da tastiera disponibili",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} messaggi selezionati", "items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza" "edit_draft": "Modifica bozza"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Scorciatoie da tastiera", "title": "Scorciatoie da tastiera",
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto", "tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard", "empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_search": "Nessun contatto corrisponde alla ricerca", "empty_search": "Nessun contatto corrisponde alla ricerca",
"empty_search_hint": "Prova con un altro termine di ricerca", "empty_search_hint": "Prova con un altro termine di ricerca",
"empty_filtered": "Nessun contatto corrisponde ai filtri",
"empty_filtered_hint": "Modifica o cancella i filtri",
"clear_search": "Cancella ricerca", "clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard", "import_vcard": "Importa vCard",
"delete_confirm_title": "Elimina contatto", "delete_confirm_title": "Elimina contatto",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importa in S/MIME", "import_to_smime": "Importa in S/MIME",
"cert_already_imported": "Certificato già importato", "cert_already_imported": "Certificato già importato",
"cert_imported": "Certificato importato", "cert_imported": "Certificato importato",
"cert_import_failed": "Importazione del certificato non riuscita" "cert_import_failed": "Importazione del certificato non riuscita",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Altre azioni",
"age_years": "{count, plural, one {1 anno} other {# anni}}",
"years_since": "{count, plural, one {1 anno fa} other {# anni fa}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nuovo contatto", "create_title": "Nuovo contatto",
@@ -1919,7 +1989,13 @@
"email_invalid": "Inserisci un indirizzo email valido", "email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido", "email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto", "save_failed": "Impossibile salvare il contatto",
"delete": "Elimina" "delete": "Elimina",
"upload_photo": "Carica foto",
"remove_photo": "Rimuovi foto",
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
"photo_invalid": "File immagine non valido",
"change_photo": "Cambia"
}, },
"groups": { "groups": {
"create": "Nuovo gruppo", "create": "Nuovo gruppo",
@@ -1979,6 +2055,37 @@
"error_create": "Impossibile creare il contatto", "error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto", "error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto" "error_delete": "Impossibile eliminare il contatto"
},
"context_menu": {
"open": "Apri",
"edit": "Modifica",
"send_email": "Invia email",
"add_to_group": "Aggiungi al gruppo",
"export_vcard": "Esporta come vCard",
"delete": "Elimina",
"call": "Chiama",
"duplicate": "Duplica",
"print": "Stampa"
},
"filters": {
"toggle": "Filtri",
"select": "Seleziona",
"clear": "Azzera",
"close": "Chiudi",
"title": "Filtri avanzati",
"organization": "Azienda",
"organization_placeholder": "es. Acme",
"job_title": "Ruolo",
"job_title_placeholder": "es. Designer",
"location": "Luogo",
"location_placeholder": "Città o paese",
"email_domain": "Dominio email",
"email_domain_placeholder": "esempio.com",
"birthday_month": "Compleanno in",
"any_month": "Qualsiasi mese",
"has_email": "Con email",
"has_phone": "Con telefono",
"has_photo": "Con foto"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provvisorio", "tentative": "Provvisorio",
"needs_action": "In attesa di risposta", "needs_action": "In attesa di risposta",
"remove": "Rimuovi", "remove": "Rimuovi",
"edit": "Modifica",
"email_placeholder": "Aggiungi indirizzo email o cerca contatti", "email_placeholder": "Aggiungi indirizzo email o cerca contatti",
"send_invitations": "Invia inviti ai partecipanti", "send_invitations": "Invia inviti ai partecipanti",
"status_summary": "{accepted} accettato/i, {pending} in attesa", "status_summary": "{accepted} accettato/i, {pending} in attesa",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "ファイル", "files": "ファイル",
"contacts": "連絡先", "contacts": "連絡先",
"sidebar_apps": "サイドバーアプリ", "sidebar_apps": "サイドバーアプリ",
"notifications": "通知" "notifications": "通知",
"layout": "レイアウト",
"reading": "閲覧",
"composing": "作成",
"content_senders": "コンテンツと送信者",
"about_data": "情報とデータ",
"debug": "デバッグ"
}, },
"tab_groups": { "tab_groups": {
"general": "一般", "general": "一般",
"account": "アカウントと身元", "account": "アカウントと身元",
"organization": "メール整理", "organization": "メール整理",
"apps": "アプリ", "apps": "アプリ",
"system": "システム" "system": "システム",
"appearance": "外観",
"mail": "メール",
"privacy": "プライバシーとセキュリティ",
"advanced": "詳細"
}, },
"appearance": { "appearance": {
"title": "外観", "title": "外観",
@@ -1275,6 +1285,10 @@
"label": "送信者ファビコン(実験的)", "label": "送信者ファビコン(実験的)",
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示" "description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
}, },
"show_avatars_in_junk": {
"label": "迷惑メールフォルダでアバターを表示",
"description": "迷惑メールフォルダの送信者にプロフィール画像とファビコンを表示します。フィッシング詐欺に正規のような見た目を与えないため、既定では無効です。"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "キーボードショートカット", "label": "キーボードショートカット",
"description": "利用可能なキーボードショートカットを表示", "description": "利用可能なキーボードショートカットを表示",
@@ -1576,6 +1590,43 @@
"items_selected": "{count}件のメールを選択", "items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集" "edit_draft": "下書きを編集"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "キーボードショートカット", "title": "キーボードショートカット",
"tip": "? キーを押すといつでもこのヘルプを表示できます", "tip": "? キーを押すといつでもこのヘルプを表示できます",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください", "empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_search": "検索に一致する連絡先がありません", "empty_search": "検索に一致する連絡先がありません",
"empty_search_hint": "別の検索語をお試しください", "empty_search_hint": "別の検索語をお試しください",
"empty_filtered": "フィルターに一致する連絡先がありません",
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
"clear_search": "検索をクリア", "clear_search": "検索をクリア",
"import_vcard": "vCardをインポート", "import_vcard": "vCardをインポート",
"delete_confirm_title": "連絡先を削除", "delete_confirm_title": "連絡先を削除",
@@ -1814,7 +1867,22 @@
"import_to_smime": "S/MIME にインポート", "import_to_smime": "S/MIME にインポート",
"cert_already_imported": "証明書はすでにインポートされています", "cert_already_imported": "証明書はすでにインポートされています",
"cert_imported": "証明書をインポートしました", "cert_imported": "証明書をインポートしました",
"cert_import_failed": "証明書のインポートに失敗しました" "cert_import_failed": "証明書のインポートに失敗しました",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "その他の操作",
"age_years": "{count}歳",
"years_since": "{count}年前"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "新しい連絡先", "create_title": "新しい連絡先",
@@ -1919,7 +1989,13 @@
"email_invalid": "有効なメールアドレスを入力してください", "email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません", "email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました", "save_failed": "連絡先の保存に失敗しました",
"delete": "削除" "delete": "削除",
"upload_photo": "写真をアップロード",
"remove_photo": "写真を削除",
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
"photo_too_large": "画像が大きすぎます (最大10MB)",
"photo_invalid": "無効な画像ファイル",
"change_photo": "変更"
}, },
"groups": { "groups": {
"create": "新しいグループ", "create": "新しいグループ",
@@ -1979,6 +2055,37 @@
"error_create": "連絡先の作成に失敗しました", "error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました", "error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました" "error_delete": "連絡先の削除に失敗しました"
},
"context_menu": {
"open": "開く",
"edit": "編集",
"send_email": "メール送信",
"add_to_group": "グループに追加",
"export_vcard": "vCardとしてエクスポート",
"delete": "削除",
"call": "電話する",
"duplicate": "複製",
"print": "印刷"
},
"filters": {
"toggle": "フィルター",
"select": "選択",
"clear": "クリア",
"close": "閉じる",
"title": "詳細フィルター",
"organization": "会社",
"organization_placeholder": "例: Acme",
"job_title": "役職",
"job_title_placeholder": "例: デザイナー",
"location": "場所",
"location_placeholder": "都市または国",
"email_domain": "メールドメイン",
"email_domain_placeholder": "example.com",
"birthday_month": "誕生月",
"any_month": "すべての月",
"has_email": "メールあり",
"has_phone": "電話あり",
"has_photo": "写真あり"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "仮承諾", "tentative": "仮承諾",
"needs_action": "未回答", "needs_action": "未回答",
"remove": "削除", "remove": "削除",
"edit": "編集",
"email_placeholder": "メールアドレスを追加または連絡先を検索", "email_placeholder": "メールアドレスを追加または連絡先を検索",
"send_invitations": "参加者に招待を送信", "send_invitations": "参加者に招待を送信",
"status_summary": "{accepted}人承諾、{pending}人保留", "status_summary": "{accepted}人承諾、{pending}人保留",
+113 -5
View File
@@ -671,14 +671,24 @@
"contacts": "연락처", "contacts": "연락처",
"encryption": "암호화", "encryption": "암호화",
"sidebar_apps": "사이드바 앱", "sidebar_apps": "사이드바 앱",
"notifications": "알림" "notifications": "알림",
"layout": "레이아웃",
"reading": "읽기",
"composing": "작성",
"content_senders": "콘텐츠 및 발신자",
"about_data": "정보 및 데이터",
"debug": "디버그"
}, },
"tab_groups": { "tab_groups": {
"general": "일반", "general": "일반",
"account": "계정 및 인증", "account": "계정 및 인증",
"organization": "메일 정리", "organization": "메일 정리",
"apps": "앱", "apps": "앱",
"system": "시스템" "system": "시스템",
"appearance": "모양",
"mail": "메일",
"privacy": "개인정보 및 보안",
"advanced": "고급"
}, },
"appearance": { "appearance": {
"title": "화면 설정", "title": "화면 설정",
@@ -1275,6 +1285,10 @@
"label": "보낸 사람 파비콘 (실험적 기능)", "label": "보낸 사람 파비콘 (실험적 기능)",
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요" "description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
}, },
"show_avatars_in_junk": {
"label": "스팸함에서 아바타 표시",
"description": "스팸함의 보낸 사람에 대한 프로필 이미지와 파비콘을 표시해요. 피싱 메일이 정상적인 메일처럼 보이지 않도록 기본적으로 꺼져 있어요."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "단축키", "label": "단축키",
"description": "사용 가능한 키보드 단축키를 확인해 보세요", "description": "사용 가능한 키보드 단축키를 확인해 보세요",
@@ -1576,6 +1590,43 @@
"items_selected": "{count}개의 메일 선택됨", "items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정" "edit_draft": "임시보관 메일 수정"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "단축키", "title": "단축키",
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요", "tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요", "empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
"empty_search": "검색된 연락처가 없어요", "empty_search": "검색된 연락처가 없어요",
"empty_search_hint": "다른 검색어로 다시 시도해 보세요", "empty_search_hint": "다른 검색어로 다시 시도해 보세요",
"empty_filtered": "필터에 일치하는 연락처가 없어요",
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
"clear_search": "검색 지우기", "clear_search": "검색 지우기",
"import_vcard": "vCard 가져오기", "import_vcard": "vCard 가져오기",
"delete_confirm_title": "연락처 삭제", "delete_confirm_title": "연락처 삭제",
@@ -1814,7 +1867,22 @@
"calendar": "캘린더", "calendar": "캘린더",
"calendar_uri": "캘린더 URL", "calendar_uri": "캘린더 URL",
"scheduling_uri": "스케줄링 URL", "scheduling_uri": "스케줄링 URL",
"freebusy_uri": "Free/Busy URL" "freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "작업 더보기",
"age_years": "{count}세",
"years_since": "{count}년 전"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "새 연락처", "create_title": "새 연락처",
@@ -1919,7 +1989,13 @@
"email_invalid": "올바른 이메일 주소를 입력해 주세요", "email_invalid": "올바른 이메일 주소를 입력해 주세요",
"email_error_inline": "이메일 형식이 잘못되었어요", "email_error_inline": "이메일 형식이 잘못되었어요",
"save_failed": "연락처를 저장하지 못했어요", "save_failed": "연락처를 저장하지 못했어요",
"delete": "삭제" "delete": "삭제",
"upload_photo": "사진 업로드",
"remove_photo": "사진 제거",
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
"photo_invalid": "잘못된 이미지 파일",
"change_photo": "변경"
}, },
"groups": { "groups": {
"create": "새 그룹", "create": "새 그룹",
@@ -1979,6 +2055,37 @@
"error_create": "연락처를 만들지 못했어요", "error_create": "연락처를 만들지 못했어요",
"error_update": "연락처를 업데이트하지 못했어요", "error_update": "연락처를 업데이트하지 못했어요",
"error_delete": "연락처를 삭제하지 못했어요" "error_delete": "연락처를 삭제하지 못했어요"
},
"context_menu": {
"open": "열기",
"edit": "편집",
"send_email": "이메일 보내기",
"add_to_group": "그룹에 추가",
"export_vcard": "vCard로 내보내기",
"delete": "삭제",
"call": "통화",
"duplicate": "복제",
"print": "인쇄"
},
"filters": {
"toggle": "필터",
"select": "선택",
"clear": "지우기",
"close": "닫기",
"title": "고급 필터",
"organization": "회사",
"organization_placeholder": "예: Acme",
"job_title": "직책",
"job_title_placeholder": "예: 디자이너",
"location": "위치",
"location_placeholder": "도시 또는 국가",
"email_domain": "이메일 도메인",
"email_domain_placeholder": "example.com",
"birthday_month": "생일 월",
"any_month": "모든 달",
"has_email": "이메일 있음",
"has_phone": "전화번호 있음",
"has_photo": "사진 있음"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "미정", "tentative": "미정",
"needs_action": "응답 필요", "needs_action": "응답 필요",
"remove": "삭제", "remove": "삭제",
"edit": "편집",
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요", "email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
"send_invitations": "참석자에게 초대장 보내기", "send_invitations": "참석자에게 초대장 보내기",
"status_summary": "{accepted}명 수락, {pending}명 대기 중", "status_summary": "{accepted}명 수락, {pending}명 대기 중",
+114 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Kontakti", "contacts": "Kontakti",
"encryption": "Šifrēšana", "encryption": "Šifrēšana",
"sidebar_apps": "Sānu joslas lietotnes", "sidebar_apps": "Sānu joslas lietotnes",
"notifications": "Paziņojumi" "notifications": "Paziņojumi",
"layout": "Izkārtojums",
"reading": "Lasīšana",
"composing": "Rakstīšana",
"content_senders": "Saturs un sūtītāji",
"about_data": "Par un dati",
"debug": "Atkļūdošana"
}, },
"tab_groups": { "tab_groups": {
"general": "Vispārīgi", "general": "Vispārīgi",
"account": "Konts un identitāte", "account": "Konts un identitāte",
"organization": "Pasta organizēšana", "organization": "Pasta organizēšana",
"apps": "Lietotnes", "apps": "Lietotnes",
"system": "Sistēma" "system": "Sistēma",
"appearance": "Izskats",
"mail": "Pasts",
"privacy": "Privātums un drošība",
"advanced": "Papildu"
}, },
"appearance": { "appearance": {
"title": "Izskats", "title": "Izskats",
@@ -1256,6 +1266,10 @@
"label": "Sūtītāju ikonas (Eksperimentāli)", "label": "Sūtītāju ikonas (Eksperimentāli)",
"description": "Rādīt vietņu ikonas kā sūtītāju avatarus" "description": "Rādīt vietņu ikonas kā sūtītāju avatarus"
}, },
"show_avatars_in_junk": {
"label": "Rādīt avatarus mēstuļu mapē",
"description": "Rādīt sūtītāju profila attēlus un ikonas mēstuļu mapē. Pēc noklusējuma izslēgts, lai pikšķerēšanas mēģinājumi neizskatītos uzticami."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Īsinājumtaustiņi", "label": "Īsinājumtaustiņi",
"description": "Skatīt pieejamos tastatūras īsinājumtaustiņus", "description": "Skatīt pieejamos tastatūras īsinājumtaustiņus",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} vēstules atlasītas", "items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu" "edit_draft": "Rediģēt melnrakstu"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Īsinājumtaustiņi", "title": "Īsinājumtaustiņi",
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību", "tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
@@ -1733,6 +1784,8 @@
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila", "empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam", "empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu", "empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
"clear_search": "Notīrīt meklēšanu", "clear_search": "Notīrīt meklēšanu",
"import_vcard": "vCard imports", "import_vcard": "vCard imports",
"delete_confirm_title": "Dzēst kontaktu", "delete_confirm_title": "Dzēst kontaktu",
@@ -1810,7 +1863,22 @@
"calendar": "Kalendārs", "calendar": "Kalendārs",
"calendar_uri": "Kalendāra URL", "calendar_uri": "Kalendāra URL",
"scheduling_uri": "Plānošanas URL", "scheduling_uri": "Plānošanas URL",
"freebusy_uri": "Pieejamības URL" "freebusy_uri": "Pieejamības URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Citas darbības",
"age_years": "{count, plural, one {1 gads} other {# gadi}}",
"years_since": "{count, plural, one {pirms 1 gada} other {pirms # gadiem}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1819,7 +1887,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Jauns kontakts", "create_title": "Jauns kontakts",
@@ -1915,7 +1985,13 @@
"email_invalid": "Ievadiet derīgu e-pasta adresi", "email_invalid": "Ievadiet derīgu e-pasta adresi",
"email_error_inline": "Nederīgs e-pasta formāts", "email_error_inline": "Nederīgs e-pasta formāts",
"save_failed": "Neizdevās saglabāt kontaktu", "save_failed": "Neizdevās saglabāt kontaktu",
"delete": "Dzēst" "delete": "Dzēst",
"upload_photo": "Augšupielādēt foto",
"remove_photo": "Noņemt foto",
"photo_hint": "JPG vai PNG, līdz 10 MB. Tiks mainīts izmērs.",
"photo_too_large": "Attēls ir pārāk liels (maks. 10 MB)",
"photo_invalid": "Nederīgs attēla fails",
"change_photo": "Mainīt"
}, },
"groups": { "groups": {
"create": "Jauna grupa", "create": "Jauna grupa",
@@ -1979,7 +2055,38 @@
"rename_category": "Pārdēvēt kategoriju", "rename_category": "Pārdēvēt kategoriju",
"category_name_label": "Kategorijas nosaukums", "category_name_label": "Kategorijas nosaukums",
"category_renamed": "Kategorija pārdēvēta", "category_renamed": "Kategorija pārdēvēta",
"category_rename_failed": "Neizdevās pārdēvēt kategoriju" "category_rename_failed": "Neizdevās pārdēvēt kategoriju",
"context_menu": {
"open": "Atvērt",
"edit": "Rediģēt",
"send_email": "Sūtīt e-pastu",
"add_to_group": "Pievienot grupai",
"export_vcard": "Eksportēt kā vCard",
"delete": "Dzēst",
"call": "Zvanīt",
"duplicate": "Dublēt",
"print": "Drukāt"
},
"filters": {
"toggle": "Filtri",
"select": "Atlasīt",
"clear": "Notīrīt",
"close": "Aizvērt",
"title": "Paplašinātie filtri",
"organization": "Uzņēmums",
"organization_placeholder": "piem. Acme",
"job_title": "Amats",
"job_title_placeholder": "piem. Dizainers",
"location": "Atrašanās vieta",
"location_placeholder": "Pilsēta vai valsts",
"email_domain": "E-pasta domēns",
"email_domain_placeholder": "piemers.lv",
"birthday_month": "Dzimšanas diena",
"any_month": "Jebkurš mēnesis",
"has_email": "Ar e-pastu",
"has_phone": "Ar tālruni",
"has_photo": "Ar foto"
}
}, },
"calendar": { "calendar": {
"title": "Kalendārs", "title": "Kalendārs",
@@ -2053,6 +2160,7 @@
"tentative": "Pagaidām", "tentative": "Pagaidām",
"needs_action": "Nepieciešama atbilde", "needs_action": "Nepieciešama atbilde",
"remove": "Noņemt", "remove": "Noņemt",
"edit": "Rediģēt",
"email_placeholder": "Pievienojiet adresi vai kontaktu", "email_placeholder": "Pievienojiet adresi vai kontaktu",
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem", "send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
"status_summary": "{accepted} pieņēmuši, {pending} gaida", "status_summary": "{accepted} pieņēmuši, {pending} gaida",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Bestanden", "files": "Bestanden",
"contacts": "Contacten", "contacts": "Contacten",
"sidebar_apps": "Zijbalk-apps", "sidebar_apps": "Zijbalk-apps",
"notifications": "Meldingen" "notifications": "Meldingen",
"layout": "Indeling",
"reading": "Lezen",
"composing": "Opstellen",
"content_senders": "Inhoud en afzenders",
"about_data": "Over en gegevens",
"debug": "Debuggen"
}, },
"tab_groups": { "tab_groups": {
"general": "Algemeen", "general": "Algemeen",
"account": "Account & identiteit", "account": "Account & identiteit",
"organization": "E-mailorganisatie", "organization": "E-mailorganisatie",
"apps": "Apps", "apps": "Apps",
"system": "Systeem" "system": "Systeem",
"appearance": "Weergave",
"mail": "E-mail",
"privacy": "Privacy en beveiliging",
"advanced": "Geavanceerd"
}, },
"appearance": { "appearance": {
"title": "Uiterlijk", "title": "Uiterlijk",
@@ -1275,6 +1285,10 @@
"label": "Afzender-favicons (Experimenteel)", "label": "Afzender-favicons (Experimenteel)",
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders" "description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
}, },
"show_avatars_in_junk": {
"label": "Avatars tonen in de map ongewenst",
"description": "Toon profielfoto's en favicons van afzenders in de map ongewenst. Standaard uitgeschakeld zodat phishingpogingen geen vertrouwd uiterlijk krijgen."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Sneltoetsen", "label": "Sneltoetsen",
"description": "Bekijk beschikbare sneltoetsen", "description": "Bekijk beschikbare sneltoetsen",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} e-mails geselecteerd", "items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken" "edit_draft": "Concept bewerken"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Sneltoetsen", "title": "Sneltoetsen",
"tip": "Druk op ? om deze hulp te tonen", "tip": "Druk op ? om deze hulp te tonen",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand", "empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
"empty_search": "Geen contacten gevonden", "empty_search": "Geen contacten gevonden",
"empty_search_hint": "Probeer een andere zoekterm", "empty_search_hint": "Probeer een andere zoekterm",
"empty_filtered": "Geen contacten voldoen aan uw filters",
"empty_filtered_hint": "Pas filters aan of wis ze",
"clear_search": "Zoekopdracht wissen", "clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren", "import_vcard": "vCard importeren",
"delete_confirm_title": "Contact verwijderen", "delete_confirm_title": "Contact verwijderen",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importeren naar S/MIME", "import_to_smime": "Importeren naar S/MIME",
"cert_already_imported": "Certificaat is al geïmporteerd", "cert_already_imported": "Certificaat is al geïmporteerd",
"cert_imported": "Certificaat geïmporteerd", "cert_imported": "Certificaat geïmporteerd",
"cert_import_failed": "Importeren van certificaat mislukt" "cert_import_failed": "Importeren van certificaat mislukt",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Meer acties",
"age_years": "{count, plural, one {1 jaar} other {# jaar}}",
"years_since": "{count, plural, one {1 jaar geleden} other {# jaar geleden}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nieuw contact", "create_title": "Nieuw contact",
@@ -1919,7 +1989,13 @@
"email_invalid": "Voer een geldig e-mailadres in", "email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat", "email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan", "save_failed": "Kon contact niet opslaan",
"delete": "Verwijderen" "delete": "Verwijderen",
"upload_photo": "Foto uploaden",
"remove_photo": "Foto verwijderen",
"photo_hint": "JPG of PNG, tot 10 MB. Wordt verkleind.",
"photo_too_large": "Afbeelding is te groot (max 10 MB)",
"photo_invalid": "Ongeldig afbeeldingsbestand",
"change_photo": "Wijzigen"
}, },
"groups": { "groups": {
"create": "Nieuwe groep", "create": "Nieuwe groep",
@@ -1979,6 +2055,37 @@
"error_create": "Kon contact niet aanmaken", "error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken", "error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen" "error_delete": "Kon contact niet verwijderen"
},
"context_menu": {
"open": "Openen",
"edit": "Bewerken",
"send_email": "E-mail verzenden",
"add_to_group": "Aan groep toevoegen",
"export_vcard": "Exporteren als vCard",
"delete": "Verwijderen",
"call": "Bellen",
"duplicate": "Dupliceren",
"print": "Afdrukken"
},
"filters": {
"toggle": "Filters",
"select": "Selecteren",
"clear": "Wissen",
"close": "Sluiten",
"title": "Geavanceerde filters",
"organization": "Bedrijf",
"organization_placeholder": "bijv. Acme",
"job_title": "Functie",
"job_title_placeholder": "bijv. Ontwerper",
"location": "Locatie",
"location_placeholder": "Stad of land",
"email_domain": "E-maildomein",
"email_domain_placeholder": "voorbeeld.nl",
"birthday_month": "Verjaardag in",
"any_month": "Elke maand",
"has_email": "Met e-mail",
"has_phone": "Met telefoon",
"has_photo": "Met foto"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Voorlopig", "tentative": "Voorlopig",
"needs_action": "Reactie vereist", "needs_action": "Reactie vereist",
"remove": "Verwijderen", "remove": "Verwijderen",
"edit": "Bewerken",
"email_placeholder": "E-mailadres toevoegen of contacten zoeken", "email_placeholder": "E-mailadres toevoegen of contacten zoeken",
"send_invitations": "Uitnodigingen sturen naar deelnemers", "send_invitations": "Uitnodigingen sturen naar deelnemers",
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting", "status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
+113 -5
View File
@@ -671,14 +671,24 @@
"contacts": "Kontakty", "contacts": "Kontakty",
"encryption": "Szyfrowanie", "encryption": "Szyfrowanie",
"sidebar_apps": "Aplikacje paska bocznego", "sidebar_apps": "Aplikacje paska bocznego",
"notifications": "Powiadomienia" "notifications": "Powiadomienia",
"layout": "Układ",
"reading": "Czytanie",
"composing": "Tworzenie",
"content_senders": "Treść i nadawcy",
"about_data": "O programie i dane",
"debug": "Debugowanie"
}, },
"tab_groups": { "tab_groups": {
"general": "Ogólne", "general": "Ogólne",
"account": "Konto i tożsamość", "account": "Konto i tożsamość",
"organization": "Organizacja poczty", "organization": "Organizacja poczty",
"apps": "Aplikacje", "apps": "Aplikacje",
"system": "System" "system": "System",
"appearance": "Wygląd",
"mail": "Poczta",
"privacy": "Prywatność i bezpieczeństwo",
"advanced": "Zaawansowane"
}, },
"appearance": { "appearance": {
"title": "Wygląd", "title": "Wygląd",
@@ -1275,6 +1285,10 @@
"label": "Favikony nadawców (eksperymentalne)", "label": "Favikony nadawców (eksperymentalne)",
"description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych" "description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych"
}, },
"show_avatars_in_junk": {
"label": "Pokazuj awatary w folderze spam",
"description": "Pokazuj zdjęcia profilowe i favikony nadawców w folderze spam. Domyślnie wyłączone, aby próby phishingu nie wyglądały na wiarygodne."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Skróty klawiszowe", "label": "Skróty klawiszowe",
"description": "Wyświetl dostępne skróty klawiszowe", "description": "Wyświetl dostępne skróty klawiszowe",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} zaznaczonych wiadomości", "items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic" "edit_draft": "Edytuj szkic"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Skróty klawiszowe", "title": "Skróty klawiszowe",
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc", "tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard", "empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
"empty_search": "Żadne kontakty nie pasują do wyszukiwania", "empty_search": "Żadne kontakty nie pasują do wyszukiwania",
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła", "empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
"clear_search": "Wyczyść wyszukiwanie", "clear_search": "Wyczyść wyszukiwanie",
"import_vcard": "Importuj vCard", "import_vcard": "Importuj vCard",
"delete_confirm_title": "Usuń kontakt", "delete_confirm_title": "Usuń kontakt",
@@ -1814,7 +1867,22 @@
"calendar": "Kalendarz", "calendar": "Kalendarz",
"calendar_uri": "Adres URL kalendarza", "calendar_uri": "Adres URL kalendarza",
"scheduling_uri": "Adres URL planowania", "scheduling_uri": "Adres URL planowania",
"freebusy_uri": "Adres URL wolny/zajęty" "freebusy_uri": "Adres URL wolny/zajęty",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Więcej działań",
"age_years": "{count, plural, one {1 rok} few {# lata} many {# lat} other {# lat}}",
"years_since": "{count, plural, one {1 rok temu} few {# lata temu} many {# lat temu} other {# lat temu}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nowy kontakt", "create_title": "Nowy kontakt",
@@ -1919,7 +1989,13 @@
"email_invalid": "Wprowadź prawidłowy adres e-mail", "email_invalid": "Wprowadź prawidłowy adres e-mail",
"email_error_inline": "Nieprawidłowy format adresu e-mail", "email_error_inline": "Nieprawidłowy format adresu e-mail",
"save_failed": "Nie udało się zapisać kontaktu", "save_failed": "Nie udało się zapisać kontaktu",
"delete": "Usuń" "delete": "Usuń",
"upload_photo": "Prześlij zdjęcie",
"remove_photo": "Usuń zdjęcie",
"photo_hint": "JPG lub PNG, do 10 MB. Zostanie zmniejszone.",
"photo_too_large": "Obraz jest za duży (maks. 10 MB)",
"photo_invalid": "Nieprawidłowy plik obrazu",
"change_photo": "Zmień"
}, },
"groups": { "groups": {
"create": "Nowa grupa", "create": "Nowa grupa",
@@ -1979,6 +2055,37 @@
"error_create": "Nie udało się utworzyć kontaktu", "error_create": "Nie udało się utworzyć kontaktu",
"error_update": "Nie udało się zaktualizować kontaktu", "error_update": "Nie udało się zaktualizować kontaktu",
"error_delete": "Nie udało się usunąć kontaktu" "error_delete": "Nie udało się usunąć kontaktu"
},
"context_menu": {
"open": "Otwórz",
"edit": "Edytuj",
"send_email": "Wyślij e-mail",
"add_to_group": "Dodaj do grupy",
"export_vcard": "Eksportuj jako vCard",
"delete": "Usuń",
"call": "Zadzwoń",
"duplicate": "Duplikuj",
"print": "Drukuj"
},
"filters": {
"toggle": "Filtry",
"select": "Zaznacz",
"clear": "Wyczyść",
"close": "Zamknij",
"title": "Filtry zaawansowane",
"organization": "Firma",
"organization_placeholder": "np. Acme",
"job_title": "Stanowisko",
"job_title_placeholder": "np. Projektant",
"location": "Lokalizacja",
"location_placeholder": "Miasto lub kraj",
"email_domain": "Domena e-mail",
"email_domain_placeholder": "przyklad.pl",
"birthday_month": "Urodziny w",
"any_month": "Dowolny miesiąc",
"has_email": "Z e-mailem",
"has_phone": "Z telefonem",
"has_photo": "Ze zdjęciem"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Wstępnie", "tentative": "Wstępnie",
"needs_action": "Wymaga działania", "needs_action": "Wymaga działania",
"remove": "Usuń", "remove": "Usuń",
"edit": "Edytuj",
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty", "email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
"send_invitations": "Wyślij zaproszenia do uczestników", "send_invitations": "Wyślij zaproszenia do uczestników",
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje", "status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Arquivos", "files": "Arquivos",
"contacts": "Contactos", "contacts": "Contactos",
"sidebar_apps": "Apps da barra lateral", "sidebar_apps": "Apps da barra lateral",
"notifications": "Notificações" "notifications": "Notificações",
"layout": "Layout",
"reading": "Leitura",
"composing": "Composição",
"content_senders": "Conteúdo e remetentes",
"about_data": "Sobre e dados",
"debug": "Depuração"
}, },
"tab_groups": { "tab_groups": {
"general": "Geral", "general": "Geral",
"account": "Conta e identidade", "account": "Conta e identidade",
"organization": "Organização de e-mail", "organization": "Organização de e-mail",
"apps": "Aplicativos", "apps": "Aplicativos",
"system": "Sistema" "system": "Sistema",
"appearance": "Aparência",
"mail": "Correio",
"privacy": "Privacidade e segurança",
"advanced": "Avançado"
}, },
"appearance": { "appearance": {
"title": "Aparência", "title": "Aparência",
@@ -1275,6 +1285,10 @@
"label": "Favicons de remetente (Experimental)", "label": "Favicons de remetente (Experimental)",
"description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais" "description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais"
}, },
"show_avatars_in_junk": {
"label": "Mostrar avatares na pasta de spam",
"description": "Exibir imagens de perfil e favicons dos remetentes na pasta de spam. Desativado por padrão para não dar aparência legítima a tentativas de phishing."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Atalhos de Teclado", "label": "Atalhos de Teclado",
"description": "Visualizar atalhos de teclado disponíveis", "description": "Visualizar atalhos de teclado disponíveis",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} e-mails selecionados", "items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho" "edit_draft": "Editar rascunho"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Atalhos de Teclado", "title": "Atalhos de Teclado",
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda", "tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard", "empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
"empty_search": "Nenhum contato encontrado", "empty_search": "Nenhum contato encontrado",
"empty_search_hint": "Tente outro termo de pesquisa", "empty_search_hint": "Tente outro termo de pesquisa",
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
"empty_filtered_hint": "Ajuste ou limpe os filtros",
"clear_search": "Limpar pesquisa", "clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard", "import_vcard": "Importar vCard",
"delete_confirm_title": "Excluir contato", "delete_confirm_title": "Excluir contato",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importar para S/MIME", "import_to_smime": "Importar para S/MIME",
"cert_already_imported": "Certificado já importado", "cert_already_imported": "Certificado já importado",
"cert_imported": "Certificado importado", "cert_imported": "Certificado importado",
"cert_import_failed": "Falha ao importar o certificado" "cert_import_failed": "Falha ao importar o certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Mais ações",
"age_years": "{count, plural, one {1 ano} other {# anos}}",
"years_since": "{count, plural, one {há 1 ano} other {há # anos}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Novo contato", "create_title": "Novo contato",
@@ -1919,7 +1989,13 @@
"email_invalid": "Por favor, insira um endereço de e-mail válido", "email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido", "email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato", "save_failed": "Falha ao salvar contato",
"delete": "Excluir" "delete": "Excluir",
"upload_photo": "Carregar foto",
"remove_photo": "Remover foto",
"photo_hint": "JPG ou PNG, até 10 MB. Será redimensionada.",
"photo_too_large": "A imagem é muito grande (máx. 10 MB)",
"photo_invalid": "Arquivo de imagem inválido",
"change_photo": "Alterar"
}, },
"groups": { "groups": {
"create": "Novo grupo", "create": "Novo grupo",
@@ -1979,6 +2055,37 @@
"error_create": "Falha ao criar contato", "error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar contato", "error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir contato" "error_delete": "Falha ao excluir contato"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar e-mail",
"add_to_group": "Adicionar ao grupo",
"export_vcard": "Exportar como vCard",
"delete": "Excluir",
"call": "Ligar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Selecionar",
"clear": "Limpar",
"close": "Fechar",
"title": "Filtros avançados",
"organization": "Empresa",
"organization_placeholder": "ex. Acme",
"job_title": "Cargo",
"job_title_placeholder": "ex. Designer",
"location": "Localização",
"location_placeholder": "Cidade ou país",
"email_domain": "Domínio de e-mail",
"email_domain_placeholder": "exemplo.com",
"birthday_month": "Aniversário em",
"any_month": "Qualquer mês",
"has_email": "Com e-mail",
"has_phone": "Com telefone",
"has_photo": "Com foto"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provisório", "tentative": "Provisório",
"needs_action": "Aguardando resposta", "needs_action": "Aguardando resposta",
"remove": "Remover", "remove": "Remover",
"edit": "Editar",
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos", "email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
"send_invitations": "Enviar convites aos participantes", "send_invitations": "Enviar convites aos participantes",
"status_summary": "{accepted} aceito(s), {pending} pendente(s)", "status_summary": "{accepted} aceito(s), {pending} pendente(s)",
+114 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Контакты", "contacts": "Контакты",
"encryption": "Шифрование", "encryption": "Шифрование",
"sidebar_apps": "Приложения боковой панели", "sidebar_apps": "Приложения боковой панели",
"notifications": "Уведомления" "notifications": "Уведомления",
"layout": "Макет",
"reading": "Чтение",
"composing": "Написание",
"content_senders": "Содержимое и отправители",
"about_data": "О программе и данные",
"debug": "Отладка"
}, },
"tab_groups": { "tab_groups": {
"general": "Общие", "general": "Общие",
"account": "Аккаунт и удостоверение", "account": "Аккаунт и удостоверение",
"organization": "Организация почты", "organization": "Организация почты",
"apps": "Приложения", "apps": "Приложения",
"system": "Система" "system": "Система",
"appearance": "Внешний вид",
"mail": "Почта",
"privacy": "Конфиденциальность и безопасность",
"advanced": "Дополнительно"
}, },
"appearance": { "appearance": {
"title": "Внешний вид", "title": "Внешний вид",
@@ -1117,7 +1127,7 @@
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую", "description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
"name_label": "Название ключа", "name_label": "Название ключа",
"name_placeholder": "напр. Скрипт резервного копирования, CI runner", "name_placeholder": "напр. Скрипт резервного копирования, CI runner",
"copy_now_warning": "Скопируйте этот API-ключ сейчас он больше не будет показан.", "copy_now_warning": "Скопируйте этот API-ключ сейчас он больше не будет показан.",
"added": "API-ключ создан", "added": "API-ключ создан",
"removed": "API-ключ удалён", "removed": "API-ключ удалён",
"add_error": "Не удалось создать API-ключ", "add_error": "Не удалось создать API-ключ",
@@ -1275,6 +1285,10 @@
"label": "Фавиконы отправителей (Экспериментально)", "label": "Фавиконы отправителей (Экспериментально)",
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей" "description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
}, },
"show_avatars_in_junk": {
"label": "Показывать аватары в папке «Спам»",
"description": "Показывать аватары и фавиконы отправителей в папке «Спам». По умолчанию отключено, чтобы фишинговые письма не выглядели правдоподобно."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Сочетания клавиш", "label": "Сочетания клавиш",
"description": "Просмотреть доступные сочетания клавиш", "description": "Просмотреть доступные сочетания клавиш",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} писем выбрано", "items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик" "edit_draft": "Редактировать черновик"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Сочетания клавиш", "title": "Сочетания клавиш",
"tip": "Нажмите ? в любое время для отображения справки", "tip": "Нажмите ? в любое время для отображения справки",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard", "empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
"empty_search": "Нет контактов, соответствующих вашему запросу", "empty_search": "Нет контактов, соответствующих вашему запросу",
"empty_search_hint": "Попробуйте другой поисковый запрос", "empty_search_hint": "Попробуйте другой поисковый запрос",
"empty_filtered": "Нет контактов, соответствующих фильтрам",
"empty_filtered_hint": "Измените или очистите фильтры",
"clear_search": "Очистить поиск", "clear_search": "Очистить поиск",
"import_vcard": "Импорт vCard", "import_vcard": "Импорт vCard",
"delete_confirm_title": "Удалить контакт", "delete_confirm_title": "Удалить контакт",
@@ -1814,7 +1867,22 @@
"calendar": "Календарь", "calendar": "Календарь",
"calendar_uri": "URL календаря", "calendar_uri": "URL календаря",
"scheduling_uri": "URL планирования", "scheduling_uri": "URL планирования",
"freebusy_uri": "URL доступности" "freebusy_uri": "URL доступности",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Другие действия",
"age_years": "{count, plural, one {# год} few {# года} many {# лет} other {# лет}}",
"years_since": "{count, plural, one {# год назад} few {# года назад} many {# лет назад} other {# лет назад}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Новый контакт", "create_title": "Новый контакт",
@@ -1919,7 +1989,13 @@
"email_invalid": "Введите корректный адрес электронной почты", "email_invalid": "Введите корректный адрес электронной почты",
"email_error_inline": "Неверный формат email", "email_error_inline": "Неверный формат email",
"save_failed": "Не удалось сохранить контакт", "save_failed": "Не удалось сохранить контакт",
"delete": "Удалить" "delete": "Удалить",
"upload_photo": "Загрузить фото",
"remove_photo": "Удалить фото",
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
"photo_invalid": "Недопустимый файл изображения",
"change_photo": "Изменить"
}, },
"groups": { "groups": {
"create": "Новая группа", "create": "Новая группа",
@@ -1979,6 +2055,37 @@
"error_create": "Не удалось создать контакт", "error_create": "Не удалось создать контакт",
"error_update": "Не удалось обновить контакт", "error_update": "Не удалось обновить контакт",
"error_delete": "Не удалось удалить контакт" "error_delete": "Не удалось удалить контакт"
},
"context_menu": {
"open": "Открыть",
"edit": "Изменить",
"send_email": "Отправить письмо",
"add_to_group": "Добавить в группу",
"export_vcard": "Экспортировать как vCard",
"delete": "Удалить",
"call": "Позвонить",
"duplicate": "Дублировать",
"print": "Печать"
},
"filters": {
"toggle": "Фильтры",
"select": "Выбрать",
"clear": "Очистить",
"close": "Закрыть",
"title": "Расширенные фильтры",
"organization": "Компания",
"organization_placeholder": "напр. Acme",
"job_title": "Должность",
"job_title_placeholder": "напр. Дизайнер",
"location": "Местоположение",
"location_placeholder": "Город или страна",
"email_domain": "Домен e-mail",
"email_domain_placeholder": "primer.ru",
"birthday_month": "День рождения в",
"any_month": "Любой месяц",
"has_email": "С эл. почтой",
"has_phone": "С телефоном",
"has_photo": "С фото"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Предварительно", "tentative": "Предварительно",
"needs_action": "Требует ответа", "needs_action": "Требует ответа",
"remove": "Удалить", "remove": "Удалить",
"edit": "Редактировать",
"email_placeholder": "Добавьте адрес или найдите контакт", "email_placeholder": "Добавьте адрес или найдите контакт",
"send_invitations": "Отправить приглашения участникам", "send_invitations": "Отправить приглашения участникам",
"status_summary": "{accepted} принято, {pending} ожидает", "status_summary": "{accepted} принято, {pending} ожидает",
+114 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Контакти", "contacts": "Контакти",
"encryption": "Шифрування", "encryption": "Шифрування",
"sidebar_apps": "Програми бічної панелі", "sidebar_apps": "Програми бічної панелі",
"notifications": "Сповіщення" "notifications": "Сповіщення",
"layout": "Макет",
"reading": "Читання",
"composing": "Написання",
"content_senders": "Вміст і відправники",
"about_data": "Про програму та дані",
"debug": "Налагодження"
}, },
"tab_groups": { "tab_groups": {
"general": "Загальний", "general": "Загальний",
"account": "Обліковий запис і ідентифікатор", "account": "Обліковий запис і ідентифікатор",
"organization": "Організація пошти", "organization": "Організація пошти",
"apps": "програми", "apps": "програми",
"system": "система" "system": "система",
"appearance": "Вигляд",
"mail": "Пошта",
"privacy": "Конфіденційність і безпека",
"advanced": "Додатково"
}, },
"appearance": { "appearance": {
"title": "Зовнішній вигляд", "title": "Зовнішній вигляд",
@@ -1117,7 +1127,7 @@
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму", "description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
"name_label": "Назва ключа", "name_label": "Назва ключа",
"name_placeholder": "напр. Скрипт резервної копії, CI runner", "name_placeholder": "напр. Скрипт резервної копії, CI runner",
"copy_now_warning": "Скопіюйте цей API-ключ зараз він більше не буде показаний.", "copy_now_warning": "Скопіюйте цей API-ключ зараз він більше не буде показаний.",
"added": "API-ключ створено", "added": "API-ключ створено",
"removed": "API-ключ видалено", "removed": "API-ключ видалено",
"add_error": "Не вдалося створити API-ключ", "add_error": "Не вдалося створити API-ключ",
@@ -1275,6 +1285,10 @@
"label": "Favicons відправника (експериментальний)", "label": "Favicons відправника (експериментальний)",
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників" "description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
}, },
"show_avatars_in_junk": {
"label": "Показувати аватари в папці «Спам»",
"description": "Показувати зображення профілю та фавікони відправників у папці «Спам». Типово вимкнено, щоб фішингові листи не виглядали достовірно."
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Комбінації клавіш", "label": "Комбінації клавіш",
"description": "Переглянути доступні комбінації клавіш", "description": "Переглянути доступні комбінації клавіш",
@@ -1576,6 +1590,43 @@
"items_selected": "Вибрано електронних листів: {count}", "items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку" "edit_draft": "Редагувати чернетку"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "Комбінації клавіш", "title": "Комбінації клавіш",
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу", "tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard", "empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
"empty_search": "Жоден контакт не відповідає вашому пошуку", "empty_search": "Жоден контакт не відповідає вашому пошуку",
"empty_search_hint": "Спробуйте інший термін пошуку", "empty_search_hint": "Спробуйте інший термін пошуку",
"empty_filtered": "Жоден контакт не відповідає фільтрам",
"empty_filtered_hint": "Змініть або очистіть фільтри",
"clear_search": "Очистити пошук", "clear_search": "Очистити пошук",
"import_vcard": "Імпорт vCard", "import_vcard": "Імпорт vCard",
"delete_confirm_title": "Видалити контакт", "delete_confirm_title": "Видалити контакт",
@@ -1814,7 +1867,22 @@
"calendar": "Календар", "calendar": "Календар",
"calendar_uri": "URL-адреса календаря", "calendar_uri": "URL-адреса календаря",
"scheduling_uri": "URL-адреса планування", "scheduling_uri": "URL-адреса планування",
"freebusy_uri": "Вільний/зайнятий URL" "freebusy_uri": "Вільний/зайнятий URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Більше дій",
"age_years": "{count, plural, one {# рік} few {# роки} many {# років} other {# років}}",
"years_since": "{count, plural, one {# рік тому} few {# роки тому} many {# років тому} other {# років тому}}"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Новий контакт", "create_title": "Новий контакт",
@@ -1919,7 +1989,13 @@
"email_invalid": "Введіть дійсну електронну адресу", "email_invalid": "Введіть дійсну електронну адресу",
"email_error_inline": "Недійсний формат електронної пошти", "email_error_inline": "Недійсний формат електронної пошти",
"save_failed": "Не вдалося зберегти контакт", "save_failed": "Не вдалося зберегти контакт",
"delete": "Видалити" "delete": "Видалити",
"upload_photo": "Завантажити фото",
"remove_photo": "Видалити фото",
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
"photo_invalid": "Недійсний файл зображення",
"change_photo": "Змінити"
}, },
"groups": { "groups": {
"create": "Нова група", "create": "Нова група",
@@ -1979,6 +2055,37 @@
"error_create": "Не вдалося створити контакт", "error_create": "Не вдалося створити контакт",
"error_update": "Не вдалося оновити контакт", "error_update": "Не вдалося оновити контакт",
"error_delete": "Не вдалося видалити контакт" "error_delete": "Не вдалося видалити контакт"
},
"context_menu": {
"open": "Відкрити",
"edit": "Редагувати",
"send_email": "Надіслати лист",
"add_to_group": "Додати до групи",
"export_vcard": "Експортувати як vCard",
"delete": "Видалити",
"call": "Зателефонувати",
"duplicate": "Дублювати",
"print": "Друк"
},
"filters": {
"toggle": "Фільтри",
"select": "Вибрати",
"clear": "Очистити",
"close": "Закрити",
"title": "Розширені фільтри",
"organization": "Компанія",
"organization_placeholder": "напр. Acme",
"job_title": "Посада",
"job_title_placeholder": "напр. Дизайнер",
"location": "Місцезнаходження",
"location_placeholder": "Місто або країна",
"email_domain": "Домен e-mail",
"email_domain_placeholder": "priklad.ua",
"birthday_month": "День народження в",
"any_month": "Будь-який місяць",
"has_email": "З ел. поштою",
"has_phone": "З телефоном",
"has_photo": "З фото"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Орієнтовний", "tentative": "Орієнтовний",
"needs_action": "Потребує дії", "needs_action": "Потребує дії",
"remove": "видалити", "remove": "видалити",
"edit": "Редагувати",
"email_placeholder": "Додайте електронну адресу або знайдіть контакти", "email_placeholder": "Додайте електронну адресу або знайдіть контакти",
"send_invitations": "Надішліть запрошення учасникам", "send_invitations": "Надішліть запрошення учасникам",
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд", "status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
+113 -5
View File
@@ -671,14 +671,24 @@
"contacts": "联系人", "contacts": "联系人",
"encryption": "加密", "encryption": "加密",
"sidebar_apps": "侧边栏应用", "sidebar_apps": "侧边栏应用",
"notifications": "通知" "notifications": "通知",
"layout": "布局",
"reading": "阅读",
"composing": "撰写",
"content_senders": "内容和发件人",
"about_data": "关于和数据",
"debug": "调试"
}, },
"tab_groups": { "tab_groups": {
"general": "通用", "general": "通用",
"account": "账户与身份", "account": "账户与身份",
"organization": "邮件组织", "organization": "邮件组织",
"apps": "应用", "apps": "应用",
"system": "系统" "system": "系统",
"appearance": "外观",
"mail": "邮件",
"privacy": "隐私和安全",
"advanced": "高级"
}, },
"appearance": { "appearance": {
"title": "外观", "title": "外观",
@@ -1275,6 +1285,10 @@
"label": "发件人图标(实验性)", "label": "发件人图标(实验性)",
"description": "使用企业发件人的网站图标作为头像" "description": "使用企业发件人的网站图标作为头像"
}, },
"show_avatars_in_junk": {
"label": "在垃圾邮件文件夹中显示头像",
"description": "在垃圾邮件文件夹中显示发件人的头像和网站图标。默认关闭,避免钓鱼邮件因熟悉的图标看起来更可信。"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "键盘快捷键", "label": "键盘快捷键",
"description": "查看可用快捷键", "description": "查看可用快捷键",
@@ -1576,6 +1590,43 @@
"items_selected": "已选择 {count} 封邮件", "items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿" "edit_draft": "编辑草稿"
}, },
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": { "shortcuts": {
"title": "键盘快捷键", "title": "键盘快捷键",
"tip": "按?随时显示此帮助", "tip": "按?随时显示此帮助",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入", "empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
"empty_search": "没有符合您搜索条件的联系人", "empty_search": "没有符合您搜索条件的联系人",
"empty_search_hint": "尝试不同的搜索词", "empty_search_hint": "尝试不同的搜索词",
"empty_filtered": "没有符合您筛选条件的联系人",
"empty_filtered_hint": "请调整或清除筛选条件",
"clear_search": "清除搜索", "clear_search": "清除搜索",
"import_vcard": "导入 vCard", "import_vcard": "导入 vCard",
"delete_confirm_title": "删除联系人", "delete_confirm_title": "删除联系人",
@@ -1814,7 +1867,22 @@
"calendar": "日历", "calendar": "日历",
"calendar_uri": "日历 URL", "calendar_uri": "日历 URL",
"scheduling_uri": "调度 URL", "scheduling_uri": "调度 URL",
"freebusy_uri": "空闲/忙碌 URL" "freebusy_uri": "空闲/忙碌 URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "更多操作",
"age_years": "{count}岁",
"years_since": "{count}年前"
}, },
"activity": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "新联系人", "create_title": "新联系人",
@@ -1919,7 +1989,13 @@
"email_invalid": "请输入有效的邮箱地址", "email_invalid": "请输入有效的邮箱地址",
"email_error_inline": "邮箱地址格式无效", "email_error_inline": "邮箱地址格式无效",
"save_failed": "保存联系人失败", "save_failed": "保存联系人失败",
"delete": "删除" "delete": "删除",
"upload_photo": "上传照片",
"remove_photo": "移除照片",
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
"photo_too_large": "图像过大 (最大 10MB)",
"photo_invalid": "无效的图像文件",
"change_photo": "更改"
}, },
"groups": { "groups": {
"create": "新建群组", "create": "新建群组",
@@ -1979,6 +2055,37 @@
"error_create": "创建联系人失败", "error_create": "创建联系人失败",
"error_update": "无法更新联系人", "error_update": "无法更新联系人",
"error_delete": "删除联系人失败" "error_delete": "删除联系人失败"
},
"context_menu": {
"open": "打开",
"edit": "编辑",
"send_email": "发送邮件",
"add_to_group": "加入群组",
"export_vcard": "导出为 vCard",
"delete": "删除",
"call": "拨打电话",
"duplicate": "复制",
"print": "打印"
},
"filters": {
"toggle": "筛选",
"select": "选择",
"clear": "清除",
"close": "关闭",
"title": "高级筛选",
"organization": "公司",
"organization_placeholder": "例如 Acme",
"job_title": "职位",
"job_title_placeholder": "例如 设计师",
"location": "所在地",
"location_placeholder": "城市或国家",
"email_domain": "邮箱域名",
"email_domain_placeholder": "example.com",
"birthday_month": "生日月份",
"any_month": "任意月份",
"has_email": "有邮箱",
"has_phone": "有电话",
"has_photo": "有照片"
} }
}, },
"calendar": { "calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "暂定", "tentative": "暂定",
"needs_action": "待处理", "needs_action": "待处理",
"remove": "移除", "remove": "移除",
"edit": "编辑",
"email_placeholder": "添加邮箱地址或搜索联系人", "email_placeholder": "添加邮箱地址或搜索联系人",
"send_invitations": "向参与者发送邀请", "send_invitations": "向参与者发送邀请",
"status_summary": "{accepted} 已接受,{pending} 待处理", "status_summary": "{accepted} 已接受,{pending} 待处理",
+933 -1283
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.14", "version": "1.5.1",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@@ -32,62 +32,62 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.24",
"@tiptap/extension-color": "^3.20.4", "@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-image": "^3.20.4", "@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.20.4", "@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-text-align": "^3.20.4", "@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.20.4", "@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.20.4", "@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.20.4", "@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.20.4", "@tiptap/starter-kit": "^3.22.4",
"asn1js": "^3.0.7", "asn1js": "^3.0.10",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.575.0", "lucide-react": "^1.8.0",
"next": "^16.1.5", "next": "^16.2.4",
"next-intl": "^4.5.8", "next-intl": "^4.9.1",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"pkijs": "^3.3.3", "pkijs": "^3.4.0",
"postal-mime": "^2.7.4", "postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6", "pvtsutils": "^1.3.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.1", "react": "^19.2.5",
"react-dom": "^19.2.1", "react-dom": "^19.2.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"webcrypto-liner": "^1.4.3", "webcrypto-liner": "^1.4.3",
"zustand": "^5.0.9" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4.2.4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3", "@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.49.0", "@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.1.5",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.1.1",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"globals": "^17.0.0", "globals": "^17.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.2.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.16" "vitest": "^4.1.5"
}, },
"overrides": { "overrides": {
"elliptic": { "elliptic": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+110 -7
View File
@@ -53,6 +53,7 @@ ENV_FILE="${SCRIPT_DIR}/.env.local"
# Config values (defaults) # Config values (defaults)
CFG_APP_NAME="Bulwark Webmail" CFG_APP_NAME="Bulwark Webmail"
CFG_JMAP_SERVER_URL="" CFG_JMAP_SERVER_URL=""
CFG_ALLOW_CUSTOM_JMAP_ENDPOINT="false"
CFG_STALWART_FEATURES="true" CFG_STALWART_FEATURES="true"
CFG_OAUTH_ENABLED="false" CFG_OAUTH_ENABLED="false"
CFG_OAUTH_ONLY="false" CFG_OAUTH_ONLY="false"
@@ -64,16 +65,23 @@ CFG_SETTINGS_SYNC_ENABLED="false"
CFG_SETTINGS_DATA_DIR="./data/settings" CFG_SETTINGS_DATA_DIR="./data/settings"
CFG_LOG_FORMAT="text" CFG_LOG_FORMAT="text"
CFG_LOG_LEVEL="info" CFG_LOG_LEVEL="info"
CFG_APP_SHORT_NAME=""
CFG_APP_DESCRIPTION=""
CFG_LOGIN_COMPANY_NAME="" CFG_LOGIN_COMPANY_NAME=""
CFG_LOGIN_LOGO_LIGHT_URL="" CFG_LOGIN_LOGO_LIGHT_URL=""
CFG_LOGIN_LOGO_DARK_URL="" CFG_LOGIN_LOGO_DARK_URL=""
CFG_FAVICON_URL="" CFG_FAVICON_URL=""
CFG_PWA_ICON_URL=""
CFG_PWA_THEME_COLOR=""
CFG_PWA_BACKGROUND_COLOR=""
CFG_APP_LOGO_LIGHT_URL="" CFG_APP_LOGO_LIGHT_URL=""
CFG_APP_LOGO_DARK_URL="" CFG_APP_LOGO_DARK_URL=""
CFG_LOGIN_IMPRINT_URL="" CFG_LOGIN_IMPRINT_URL=""
CFG_LOGIN_PRIVACY_POLICY_URL="" CFG_LOGIN_PRIVACY_POLICY_URL=""
CFG_LOGIN_WEBSITE_URL="" CFG_LOGIN_WEBSITE_URL=""
CFG_EXTENSION_DIRECTORY_URL=""
CFG_DEPLOY_METHOD="" CFG_DEPLOY_METHOD=""
CFG_HOSTNAME="0.0.0.0"
CFG_PORT="3000" CFG_PORT="3000"
CURRENT_STEP=0 CURRENT_STEP=0
@@ -593,9 +601,19 @@ screen_server_config() {
prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES" prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES"
echo "" echo ""
echo -e " ${BOLD}Network${RESET}" echo -e " ${DIM}Adds a \"JMAP Server\" field to the login form so users can${RESET}"
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}" echo -e " ${DIM}connect to any JMAP-compatible server (not just the one above).${RESET}"
echo -e " ${DIM}The external server must allow this domain in its CORS headers.${RESET}"
prompt_yesno "Allow users to override the JMAP server at login?" "$CFG_ALLOW_CUSTOM_JMAP_ENDPOINT" "CFG_ALLOW_CUSTOM_JMAP_ENDPOINT"
echo "" echo ""
echo -e " ${BOLD}Network${RESET}"
echo -e " ${DIM}The address the server binds to. Use \"0.0.0.0\" for all IPv4${RESET}"
echo -e " ${DIM}interfaces or \"::\" for dual-stack IPv4+IPv6.${RESET}"
echo ""
prompt_value "Hostname" "$CFG_HOSTNAME" "CFG_HOSTNAME"
echo ""
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
prompt_value "Port" "$CFG_PORT" "CFG_PORT" prompt_value "Port" "$CFG_PORT" "CFG_PORT"
draw_footer draw_footer
@@ -770,32 +788,65 @@ screen_login_customization() {
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field." echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
echo "" echo ""
echo -e " ${BOLD}App Identity${RESET}"
echo -e " ${DIM}Short name used where space is limited (e.g. mobile home screen).${RESET}"
echo -e " ${DIM}Defaults to the application name set in step 1.${RESET}"
prompt_value "Short app name" "$CFG_APP_SHORT_NAME" "CFG_APP_SHORT_NAME"
echo ""
echo -e " ${DIM}Description shown by the OS when installing as a PWA.${RESET}"
prompt_value "App description" "$CFG_APP_DESCRIPTION" "CFG_APP_DESCRIPTION"
echo ""
echo -e " ${BOLD}Icons${RESET}"
echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}" echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}"
echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}" echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL" prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
echo "" echo ""
echo -e " ${DIM}Source image for PWA icons (192×192 and 512×512 are auto-generated).${RESET}"
echo -e " ${DIM}Falls back to FAVICON_URL if blank.${RESET}"
prompt_value "PWA icon URL" "$CFG_PWA_ICON_URL" "CFG_PWA_ICON_URL"
echo ""
echo -e " ${BOLD}PWA Colors${RESET}"
echo -e " ${DIM}Hex color (e.g. #3b82f6) used for the browser UI chrome when installed.${RESET}"
prompt_value "PWA theme color" "$CFG_PWA_THEME_COLOR" "CFG_PWA_THEME_COLOR"
echo ""
echo -e " ${DIM}Hex color shown on the PWA splash screen while loading.${RESET}"
prompt_value "PWA background color" "$CFG_PWA_BACKGROUND_COLOR" "CFG_PWA_BACKGROUND_COLOR"
echo ""
echo -e " ${BOLD}Logos${RESET}"
echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}" echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}" echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}"
prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL" prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
echo "" echo ""
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL" prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
echo "" echo ""
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
echo ""
echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}" echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}"
echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}" echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}"
prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL" prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
echo "" echo ""
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL" prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
echo "" echo ""
echo -e " ${BOLD}Login Page Links${RESET}"
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
echo ""
prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL" prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
echo "" echo ""
prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL" prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL"
echo "" echo ""
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL" prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL"
echo ""
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then echo -e " ${BOLD}Extension Directory${RESET}"
echo -e " ${DIM}URL of the BulwarkMail extension directory for the admin marketplace.${RESET}"
echo -e " ${DIM}Set this to enable browsing and installing plugins/themes.${RESET}"
echo -e " ${DIM}Leave blank to disable the marketplace.${RESET}"
prompt_value "Extension directory URL" "$CFG_EXTENSION_DIRECTORY_URL" "CFG_EXTENSION_DIRECTORY_URL"
if [[ -z "$CFG_APP_SHORT_NAME" && -z "$CFG_APP_DESCRIPTION" && -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_PWA_ICON_URL" && -z "$CFG_PWA_THEME_COLOR" && -z "$CFG_PWA_BACKGROUND_COLOR" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" && -z "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
echo "" echo ""
note "No branding configured. The app will use defaults." note "No branding configured. The app will use defaults."
fi fi
@@ -866,7 +917,9 @@ screen_summary() {
echo -e " ${CYAN}${BOLD}SERVER${RESET}" echo -e " ${CYAN}${BOLD}SERVER${RESET}"
echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}" echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}"
echo -e " JMAP Server URL ....... ${BOLD}${CFG_JMAP_SERVER_URL}${RESET}" echo -e " JMAP Server URL ....... ${BOLD}${CFG_JMAP_SERVER_URL}${RESET}"
echo -e " Allow Custom JMAP ..... ${BOLD}${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}${RESET}"
echo -e " Stalwart Features ..... ${BOLD}${CFG_STALWART_FEATURES}${RESET}" echo -e " Stalwart Features ..... ${BOLD}${CFG_STALWART_FEATURES}${RESET}"
echo -e " Hostname .............. ${BOLD}${CFG_HOSTNAME}${RESET}"
echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}" echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}"
echo "" echo ""
@@ -905,9 +958,19 @@ screen_summary() {
# Login page # Login page
echo -e " ${CYAN}${BOLD}BRANDING${RESET}" echo -e " ${CYAN}${BOLD}BRANDING${RESET}"
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then if [[ -n "$CFG_APP_SHORT_NAME" || -n "$CFG_APP_DESCRIPTION" || -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_PWA_ICON_URL" || -n "$CFG_PWA_THEME_COLOR" || -n "$CFG_PWA_BACKGROUND_COLOR" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
[[ -n "$CFG_APP_SHORT_NAME" ]] && \
echo -e " Short Name ............ ${BOLD}${CFG_APP_SHORT_NAME}${RESET}"
[[ -n "$CFG_APP_DESCRIPTION" ]] && \
echo -e " Description ........... ${BOLD}${CFG_APP_DESCRIPTION}${RESET}"
[[ -n "$CFG_FAVICON_URL" ]] && \ [[ -n "$CFG_FAVICON_URL" ]] && \
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}" echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}"
[[ -n "$CFG_PWA_ICON_URL" ]] && \
echo -e " PWA Icon URL .......... ${BOLD}${CFG_PWA_ICON_URL}${RESET}"
[[ -n "$CFG_PWA_THEME_COLOR" ]] && \
echo -e " PWA Theme Color ....... ${BOLD}${CFG_PWA_THEME_COLOR}${RESET}"
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && \
echo -e " PWA Background Color .. ${BOLD}${CFG_PWA_BACKGROUND_COLOR}${RESET}"
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \ [[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}" echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \ [[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
@@ -929,6 +992,13 @@ screen_summary() {
fi fi
echo "" echo ""
# Extensions
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
echo -e " ${CYAN}${BOLD}EXTENSIONS${RESET}"
echo -e " Directory URL ......... ${BOLD}${CFG_EXTENSION_DIRECTORY_URL}${RESET}"
echo ""
fi
# Deployment # Deployment
echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}" echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}"
case "$CFG_DEPLOY_METHOD" in case "$CFG_DEPLOY_METHOD" in
@@ -978,10 +1048,15 @@ write_env_file() {
# -- JMAP Server (required) --------------------------------------------------- # -- JMAP Server (required) ---------------------------------------------------
APP_NAME=${CFG_APP_NAME} APP_NAME=${CFG_APP_NAME}
JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL} JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL}
ALLOW_CUSTOM_JMAP_ENDPOINT=${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}
# -- Stalwart Mail Server Integration ----------------------------------------- # -- Stalwart Mail Server Integration -----------------------------------------
STALWART_FEATURES=${CFG_STALWART_FEATURES} STALWART_FEATURES=${CFG_STALWART_FEATURES}
# -- Server Listen Address ----------------------------------------------------
HOSTNAME=${CFG_HOSTNAME}
PORT=${CFG_PORT}
# -- OAuth / OpenID Connect --------------------------------------------------- # -- OAuth / OpenID Connect ---------------------------------------------------
OAUTH_ENABLED=${CFG_OAUTH_ENABLED} OAUTH_ENABLED=${CFG_OAUTH_ENABLED}
ENVEOF ENVEOF
@@ -1025,7 +1100,12 @@ LOG_LEVEL=${CFG_LOG_LEVEL}
# -- Branding ------------------------------------------------------------------ # -- Branding ------------------------------------------------------------------
ENVEOF ENVEOF
[[ -n "$CFG_APP_SHORT_NAME" ]] && echo "APP_SHORT_NAME=${CFG_APP_SHORT_NAME}" >> "$ENV_FILE"
[[ -n "$CFG_APP_DESCRIPTION" ]] && echo "APP_DESCRIPTION=${CFG_APP_DESCRIPTION}" >> "$ENV_FILE"
[[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE" [[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE"
[[ -n "$CFG_PWA_ICON_URL" ]] && echo "PWA_ICON_URL=${CFG_PWA_ICON_URL}" >> "$ENV_FILE"
[[ -n "$CFG_PWA_THEME_COLOR" ]] && echo "PWA_THEME_COLOR=${CFG_PWA_THEME_COLOR}" >> "$ENV_FILE"
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && echo "PWA_BACKGROUND_COLOR=${CFG_PWA_BACKGROUND_COLOR}" >> "$ENV_FILE"
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE" [[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE" [[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE" [[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE"
@@ -1035,6 +1115,29 @@ ENVEOF
[[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo "LOGIN_PRIVACY_POLICY_URL=${CFG_LOGIN_PRIVACY_POLICY_URL}" >> "$ENV_FILE" [[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo "LOGIN_PRIVACY_POLICY_URL=${CFG_LOGIN_PRIVACY_POLICY_URL}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo "LOGIN_WEBSITE_URL=${CFG_LOGIN_WEBSITE_URL}" >> "$ENV_FILE" [[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo "LOGIN_WEBSITE_URL=${CFG_LOGIN_WEBSITE_URL}" >> "$ENV_FILE"
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
cat >> "$ENV_FILE" << ENVEOF
# -- Extension Directory / Marketplace ----------------------------------------
EXTENSION_DIRECTORY_URL=${CFG_EXTENSION_DIRECTORY_URL}
ENVEOF
fi
cat >> "$ENV_FILE" << ENVEOF
# -- Advanced (uncomment and set if needed) -----------------------------------
# SESSION_SECRET_FILE=/run/secrets/session_secret
# OAUTH_CLIENT_SECRET_FILE=/run/secrets/oauth_client_secret
# OAUTH_SCOPES=openid profile email
# OAUTH_EXTRA_SCOPES=
# COOKIE_SAME_SITE=lax
# COOKIE_SECURE=true
# TRUSTED_PROXY_DEPTH=1
# ALLOWED_FRAME_ANCESTORS='none'
# ADMIN_DATA_DIR=./data/admin
# ADMIN_SESSION_TTL=3600
ENVEOF
echo -e " ${OK} Written ${BOLD}.env.local${RESET}" echo -e " ${OK} Written ${BOLD}.env.local${RESET}"
} }
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
-857
View File
@@ -1,857 +0,0 @@

Internet Engineering Task Force (IETF) N. Jenkins, Ed.
Request for Comments: 9670 Fastmail
Updates: 8620 November 2024
Category: Standards Track
ISSN: 2070-1721
JSON Meta Application Protocol (JMAP) Sharing
Abstract
This document specifies a data model for sharing data between users
using the JSON Meta Application Protocol (JMAP). Future documents
can reference this document when defining data types to support a
consistent model of sharing.
Status of This Memo
This is an Internet Standards Track document.
This document is a product of the Internet Engineering Task Force
(IETF). It represents the consensus of the IETF community. It has
received public review and has been approved for publication by the
Internet Engineering Steering Group (IESG). Further information on
Internet Standards is available in Section 2 of RFC 7841.
Information about the current status of this document, any errata,
and how to provide feedback on it may be obtained at
https://www.rfc-editor.org/info/rfc9670.
Copyright Notice
Copyright (c) 2024 IETF Trust and the persons identified as the
document authors. All rights reserved.
This document is subject to BCP 78 and the IETF Trust's Legal
Provisions Relating to IETF Documents
(https://trustee.ietf.org/license-info) in effect on the date of
publication of this document. Please review these documents
carefully, as they describe your rights and restrictions with respect
to this document. Code Components extracted from this document must
include Revised BSD License text as described in Section 4.e of the
Trust Legal Provisions and are provided without warranty as described
in the Revised BSD License.
Table of Contents
1. Introduction
1.1. Notational Conventions
1.2. Terminology
1.3. Data Model Overview
1.4. Subscribing to Shared Data
1.5. Addition to the Capabilities Object
1.5.1. urn:ietf:params:jmap:principals
1.5.2. urn:ietf:params:jmap:principals:owner
2. Principals
2.1. Principal/get
2.2. Principal/changes
2.3. Principal/set
2.4. Principal/query
2.4.1. Filtering
2.5. Principal/queryChanges
3. ShareNotifications
3.1. ShareNotification/get
3.2. ShareNotification/changes
3.3. ShareNotification/set
3.4. ShareNotification/query
3.4.1. Filtering
3.4.2. Sorting
3.5. ShareNotification/queryChanges
4. Framework for Shared Data
4.1. Example
5. Internationalization Considerations
6. Security Considerations
6.1. Spoofing
6.2. Unnoticed Sharing
6.3. Denial of Service
6.4. Unauthorized Principals
7. IANA Considerations
7.1. JMAP Capability Registration for "principals"
7.2. JMAP Capability Registration for "principals:owner"
7.3. JMAP Data Type Registration for "Principal"
7.4. JMAP Data Type Registration for "ShareNotification"
8. References
8.1. Normative References
8.2. Informative References
Author's Address
1. Introduction
The JSON Meta Application Protocol (JMAP) [RFC8620] is a generic
protocol for synchronizing data, such as mail, calendars, or
contacts, between a client and a server. It is optimized for mobile
and web environments and provides a consistent interface to query,
read, and modify different data types, including comprehensive error
handling.
This specification defines a data model to represent entities in a
collaborative environment and a framework for sharing data between
them that can be used to provide a consistent sharing model for
different data types. It does not define _what_ may be shared or the
granularity of permissions, as this will depend on the data in
question.
1.1. Notational Conventions
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and
"OPTIONAL" in this document are to be interpreted as described in
BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all
capitals, as shown here.
Type signatures, examples, and property descriptions in this document
follow the conventions established in Section 1.1 of [RFC8620]. Data
types defined in the core specification are also used in this
document.
Examples of API exchanges only show the methodCalls array of the
Request object or the methodResponses array of the Response object.
For compactness, the rest of the Request/Response object is omitted.
1.2. Terminology
The same terminology is used in this document as in the core JMAP
specification. See [RFC8620], Section 1.6.
The terms "Principal" and "ShareNotification" (with this specific
capitalization) are used to refer to the data types defined in this
document and instances of those data types.
1.3. Data Model Overview
A Principal (see Section 2) represents an individual, team, or
resource (e.g., a room or projector). The object contains
information about the entity being represented, such as a name,
description, and time zone. It may also hold domain-specific
information. A Principal may be associated with zero or more
Accounts (see [RFC8620], Section 1.6.2) containing data belonging to
the Principal. Managing the set of Principals within a system is out
of scope for this specification, as it is highly domain specific. It
is likely to map directly from a directory service or other user
management system.
Data types may allow users to share data with others by assigning
permissions to Principals. When a user's permissions are changed, a
ShareNotification object is created for them so a client can inform
the user of the changes.
1.4. Subscribing to Shared Data
Permissions determine whether a user _may_ access data but not
whether they _want_ to. Some shared data is of equal importance as
the user's own, while other data is just there should the user wish
to explicitly go find it. Clients will often want to differentiate
the two. For example, a company may share mailing list archives for
all departments with all employees, but a user may only generally be
interested in the few they belong to. They would have _permission_
to access many mailboxes but can _subscribe_ to just the ones they
care about. The client would provide separate interfaces for reading
mail in subscribed mailboxes and browsing all mailboxes they have
permission to access in order to manage those that they are
subscribed to.
The JMAP Session object (see [RFC8620], Section 2) is defined to
include an object in the "accounts" property for every Account that
the user has access to. Collaborative systems may share data between
a very large number of Principals, most of which the user does not
care about day to day. For servers implementing this specification,
the Session object MUST only include Accounts where either the user
is subscribed to at least one record (see [RFC8620], Section 1.6.3)
in the Account or the Account belongs to the user. StateChange
events ([RFC8620], Section 7.1) for changes to data SHOULD only be
sent for data the user has subscribed to and MUST NOT be sent for any
Account where the user is not subscribed to any records in the
Account, except where that Account belongs to the user.
The server MAY reject the user's attempt to subscribe to some
resources even if they have permission to access them (e.g., a
calendar representing a location).
A user can query the set of Principals they have access to with
"Principal/query" (see Section 2.4). The Principal object will
contain an Account object for all Accounts where the user has
permission to access data for that Principal, even if they are not
yet subscribed.
1.5. Addition to the Capabilities Object
The capabilities object is returned as part of the JMAP Session
object; see [RFC8620], Section 2. This document defines two
additional capability URIs.
1.5.1. urn:ietf:params:jmap:principals
The urn:ietf:params:jmap:principals capability represents support for
the Principal and ShareNotification data types and associated API
methods.
The value of this property in the JMAP Session "capabilities"
property is an empty object.
The value of this property in an Account's "accountCapabilities"
property is an object that MUST contain the following information on
server capabilities and permissions for that Account:
*currentUserPrincipalId*: Id|null
The id of the Principal in this Account that corresponds to the
user fetching this object, if any.
1.5.2. urn:ietf:params:jmap:principals:owner
The URI urn:ietf:params:jmap:principals:owner is solely used as a key
in an Account's "accountCapabilities" property. It does not appear
in the JMAP Session capabilities -- support is indicated by the
urn:ietf:params:jmap:principals URI being present in the session
capabilities.
If urn:ietf:params:jmap:principals:owner is a key in an Account's
"accountCapabilities" property, that Account (and the data therein)
is owned by a Principal. Some Accounts may not be owned by a
Principal (e.g., the Account that contains the data for the
Principals themselves), in which case this property is omitted.
The value of this property is an object with the following
properties:
*accountIdForPrincipal*: Id
The id of an Account with the urn:ietf:params:jmap:principals
capability that contains the corresponding Principal object.
*principalId*:Id
The id of the Principal that owns this Account.
2. Principals
A Principal represents an individual, a group, a location (e.g., a
room), a resource (e.g., a projector), or another entity in a
collaborative environment. Sharing in JMAP is generally configured
by assigning rights to certain data within an Account to other
Principals. For example, a user may assign permission to read their
calendar to a Principal representing another user or their team.
In a shared environment, such as a workplace, a user may have access
to a large number of Principals.
In most systems, the user will have access to a single Account
containing Principal objects. In some situations, for example, when
aggregating data from different places, there may be multiple
Accounts containing Principal objects.
A *Principal* object has the following properties:
*id*: Id (immutable; server-set)
The id of the Principal.
*type*: String
This MUST be one of the following values:
* "individual": This represents a single person.
* "group": This represents a group of other Principals.
* "resource": This represents some resource, e.g., a projector.
* "location": This represents a location.
* "other": This represents some other undefined Principal.
*name*: String
The name of the Principal, e.g., "Jane Doe" or "Room 4B".
*description*: String|null
A longer description of the Principal, for example, details about
the facilities of a resource, or null if no description is
available.
*email*: String|null
An email address for the Principal, or null if no email is
available. If given, the value MUST conform to the "addr-spec"
syntax, as defined in [RFC5322], Section 3.4.1.
*timeZone*: String|null
The time zone for this Principal, if known. If not null, the
value MUST be a time zone name from the IANA Time Zone Database
[IANA-TZDB].
*capabilities*: String[Object] (server-set)
A map of JMAP capability URIs to domain-specific information about
the Principal in relation to that capability, as defined in the
document that registered the capability.
*accounts*: Id[Account]|null (server-set)
A map of Account id to Account object for each JMAP Account
containing data for this Principal that the user has access to, or
null if none.
2.1. Principal/get
This is a standard "/get" method as described in [RFC8620],
Section 5.1.
2.2. Principal/changes
This is a standard "/changes" method as described in [RFC8620],
Section 5.2.
| Note: Implementations backed by an external directory may be
| unable to calculate changes. In this case, they will always
| return a "cannotCalculateChanges" error as described in the
| core JMAP specification.
2.3. Principal/set
This is a standard "/set" method as described in [RFC8620],
Section 5.3.
Managing Principals is likely tied to a directory service or some
other vendor-specific solution. This management may occur out of
band or via an additional capability defined elsewhere. Allowing
direct user modification of properties has security considerations,
as noted in Section 6. A server MUST reject any change it doesn't
allow with a "forbidden" SetError.
Where a server does support changes via this API, it SHOULD allow an
update to the "name", "description", and "timeZone" properties of the
Principal with the same id as the "currentUserPrincipalId" in the
Account capabilities. This allows the user to update their own
details.
2.4. Principal/query
This is a standard "/query" method as described in [RFC8620],
Section 5.5.
2.4.1. Filtering
A *FilterCondition* object has the following properties, all of which
are optional:
*accountIds*: String[]
A list of Account ids. The Principal matches if any of the ids in
this list are keys in the Principal's "accounts" property (i.e.,
if any of the Account ids belong to the Principal).
*email*: String
The email property of the Principal contains the given string.
*name*: String
The name property of the Principal contains the given string.
*text*: String
The name, email, or description property of the Principal contains
the given string.
*type*: String
The type must be exactly as given to match the condition.
*timeZone*: String
The timeZone must be exactly as given to match the condition.
All given conditions in the FilterCondition object must match for the
Principal to match.
Text matches for "contains" SHOULD be simple substring matches.
2.5. Principal/queryChanges
This is a standard "/queryChanges" method as described in [RFC8620],
Section 5.6.
| Note: Implementations backed by an external directory may be
| unable to calculate changes. In this case, they will always
| return a "cannotCalculateChanges" error as described in the
| core JMAP specification.
3. ShareNotifications
The ShareNotification data type records when the user's permissions
to access a shared object changes. ShareNotifications are only
created by the server; users cannot create them explicitly. They are
stored in the same Account as the Principals.
Clients may present the list of notifications to the user and allow
the user to dismiss them. To dismiss a notification, use a standard
"/set" call to destroy it.
The server SHOULD create a ShareNotification whenever the user's
permissions change on an object. It MAY choose not to create a
notification for permission changes to a group Principal, even if the
user is in the group, if this is more likely to be overwhelming than
helpful, or if it would create excessive notifications within the
system.
The server MAY limit the maximum number of notifications it will
store for a user. When the limit is reached, any new notification
will cause the previously oldest notification to be automatically
deleted.
The server MAY coalesce notifications if appropriate or remove
notifications after a certain period of time or that it deems are no
longer relevant.
A *ShareNotification* object has the following properties:
*id*: String (immutable; server-set)
The id of the ShareNotification.
*created*: UTCDate (immutable; server-set)
The time this notification was created.
*changedBy*: Entity (immutable; server-set)
Who made the change. An *Entity* object has the following
properties:
*name*: String
The name of the entity who made the change.
*email*: String|null
The email of the entity who made the change, or null if no
email is available.
*principalId*: Id|null
The id of the Principal corresponding to the entity who made
the change, or null if no associated Principal.
*objectType*: String (immutable; server-set)
The name of the data type for the object whose permissions have
changed, as registered in the IANA "JMAP Data Types" registry
[IANA-JMAP], e.g., "Calendar" or "Mailbox".
*objectAccountId*: Id (immutable; server-set)
The id of the Account where this object exists.
*objectId*: Id (immutable; server-set)
The id of the object that this notification is about.
*oldRights*: String[Boolean]|null (immutable; server-set)
The "myRights" property of the object for the user before the
change.
*newRights*: String[Boolean]|null (immutable; server-set)
The "myRights" property of the object for the user after the
change.
*name*: String (immutable; server-set)
The name of the object at the time the notification was made.
Determining the name will depend on the data type in question.
For example, it might be the "title" property of a CalendarEvent
or the "name" of a Mailbox. The name is to show users who have
had their access rights to the object removed what it is that they
can no longer access.
3.1. ShareNotification/get
This is a standard "/get" method as described in [RFC8620],
Section 5.1.
3.2. ShareNotification/changes
This is a standard "/changes" method as described in [RFC8620],
Section 5.2.
3.3. ShareNotification/set
This is a standard "/set" method as described in [RFC8620],
Section 5.3.
Only destroy is supported; any attempt to create/update MUST be
rejected with a "forbidden" SetError.
3.4. ShareNotification/query
This is a standard "/query" method as described in [RFC8620],
Section 5.5.
3.4.1. Filtering
A *FilterCondition* object has the following properties, all of which
are optional:
*after*: UTCDate|null
The creation date must be on or after this date to match the
condition.
*before*: UTCDate|null
The creation date must be before this date to match the condition.
*objectType*: String
The objectType value must be identical to the given value to match
the condition.
*objectAccountId*: Id
The objectAccountId value must be identical to the given value to
match the condition.
All given conditions in the FilterCondition object must match for the
ShareNotification to match.
3.4.2. Sorting
The "created" property MUST be supported for sorting.
3.5. ShareNotification/queryChanges
This is a standard "/queryChanges" method as described in [RFC8620],
Section 5.6.
4. Framework for Shared Data
Shareable data types MUST define the following three properties:
*isSubscribed*: Boolean
The value true indicates that the user wishes to subscribe to see
this data. The value false indicates that the user does not wish
to subscribe to see this data. The initial value for this
property when data is shared by another user is implementation
dependent, although data types may give advice on appropriate
defaults.
*myRights*: String[Boolean]
The set of permissions the user currently has. Appropriate
permissions are domain specific and must be defined per data type.
Each key is the name of a permission defined for that data type.
The value for the key is true if the user has the permission or
false if they do not.
*shareWith*: Id[String[Boolean]]|null
The value of this property is null if the data is not shared with
anyone. Otherwise, it is a map where each key is the id of a
Principal with which this data is shared, and the value associated
with that key is the rights to give that Principal, in the same
format as the "myRights" property. The Account id for the
Principal id can be found in the capabilities of the Account this
object is in (see Section 1.5.2).
Users with appropriate permission may set this property to modify
who the data is shared with. The Principal that owns the Account
that this data is in MUST NOT be in the map, since the owner's
rights are implicit.
4.1. Example
Suppose we are designing a data model for a very simple to-do list.
There is a Todo data type representing a single item to do, each of
which belongs to a single TodoList. The specification makes the
TodoLists shareable by referencing this document and defining the
common properties.
First, it would define a set of domain-specific rights. For example,
a TodoListRights object may have the following properties:
*mayRead*: Boolean
The user may fetch this TodoList and any Todos that belong to this
TodoList.
*mayWrite*: Boolean
The user may create, update, or destroy Todos that belong to this
TodoList and may change the "name" property of this TodoList.
*mayAdmin*: Boolean
The user may see and modify the "myRights" property of this
TodoList and may destroy this TodoList.
Then in the TodoList data type, we would include the three common
properties described in Section 4, in addition to any type-specific
properties (like "name" in this case):
*id*: Id (immutable; server-set)
The id of the object.
*name*: String
A name for this list of Todos.
*isSubscribed*: Boolean
True if the user has indicated they wish to see this list. If
false, clients should not display this TodoList with the user's
other TodoLists but should provide a means for users to see and
subscribe to all TodoLists that have been shared with them.
*myRights*: TodoListRights
The set of permissions the user currently has for this TodoList.
*shareWith*: Id[TodoListRights]|null
If not shared with anyone, the value is null. Otherwise, it's a
map where the keys are Principal ids and the values are the rights
given to those Principals. Users with the "mayAdmin" right may
set this property to modify who the data is shared with. The
Principal that owns the Account that this data is in MUST NOT be
in the map; their rights are implicit.
We would also define a new Principal capability with two properties:
*accountId*: Id|null
The accountId containing the Todo data for this Principal, if it
has been shared with the requesting user.
*mayShareWith*: Boolean
The user may give this Principal permission to access a TodoList.
A client wishing to let the user configure sharing would look at the
"capabilities" for the Account containing the user's Todo data and
find the "urn:ietf:params:jmap:principals:owner" property, as per
Section 1.5.2. For example, the JMAP Session object might contain:
{
"accounts": {
"u12345678": {
"name": "jane.doe@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:com.example:jmap:todo": {},
"urn:ietf:params:jmap:principals:owner": {
"accountIdForPrincipal": "u33084183",
"principalId": "P105aga511jaa"
}
}
},
...
},
...
}
Figure 1: Part of a JMAP Session Object
From this, the client now knows which Account has the Principal data,
and it can fetch the list of Principals and offer to share it with
the user by making an API request like this:
[[ "Principal/get", {
"accountId": "u33084183",
"ids": null
}, "0" ]]
Figure 2: "methodCalls" Property of a JMAP Request
Here's an example response (where "Joe Bloggs" is another user that
this user could share their TodoList with; Joe has not shared any of
their own data with this user, so the "accounts" property is null):
[[ "Principal/get", {
"accountId": "u33084183",
"state": "7b8eff5zz",
"list": [{
"id": "P2342fnddd20",
"type": "individual",
"name": "Joe Bloggs",
"description": null,
"email": "joe.bloggs@example.com",
"timeZone": "Australia/Melbourne",
"capabilities": {
"urn:com.example:jmap:todo": {
"accountId": null,
"mayShareWith": true
}
},
"accounts": null
}, {
"id": "P674pp24095qo49pr",
"name": "Board room",
"type": "location",
...
}, ... ],
"notFound": []
}, "0" ]]
Figure 3: "methodResponses" Property of a JMAP Response
A TodoList can be shared with "Joe Bloggs" by updating its shareWith
property, as in this example request:
[[ "TodoList/set", {
"accountId": "u12345678",
"update": {
"tl01n231": {
"shareWith": {
"P2342fnddd20": {
"mayRead": true,
"mayWrite": true,
"mayAdmin": false
}
}
}
}
}, "0" ]]
Figure 4: "methodCalls" Property of a JMAP Request
5. Internationalization Considerations
Experience has shown that unrestricted use of Unicode can lead to
problems such as inconsistent rendering, users reading text and
interpreting it differently than intended, and unexpected results
when copying text from one location to another. Servers MAY choose
to mitigate this by restricting the set of characters allowed in
otherwise unconstrained String fields. The FreeformClass, as
documented in [RFC8264], Section 4.3, might be a good starting point
for this.
Attempts to set a value containing code points outside of the
permissible set can be handled in a few ways by the server. The
first option is to simply strip the forbidden characters and store
the resulting string. This is likely to be appropriate for control
characters, for example, where they can end up in data accidentally
due to copy-and-paste issues and are probably invisible to the end
user. JMAP allows the server to transform data on create/update, as
long as any changed properties are returned to the client in the
"/set" response so it knows what has changed, as per [RFC8620],
Section 5.3. Alternatively, the server MAY just reject the create/
update with an "invalidProperties" SetError.
6. Security Considerations
All security considerations of JMAP [RFC8620] apply to this
specification. Additional considerations are detailed below.
6.1. Spoofing
Allowing users to edit their own Principal's name (and, to a lesser
extent, email, description, or type) could allow a user to change
their Principal to look like another user in the system, potentially
tricking others into sharing private data with them. Servers may
choose to forbid this and SHOULD keep logs of such changes to provide
an audit trail.
Note that simply forbidding the use of a name already in the system
is insufficient protection, as a malicious user could still change
their name to something easily confused with the existing name by
using trivial misspellings or visually similar Unicode characters.
6.2. Unnoticed Sharing
Sharing data with another user allows someone to turn a transitory
account compromise (e.g., brief access to an unlocked or logged-in
client) into a persistent compromise (by setting up sharing with a
user that is controlled by the attacker). This can be mitigated by
requiring further authorization for configuring sharing or sending
notifications to the sharer via another channel whenever a new
permission is added.
6.3. Denial of Service
By creating many changes to the sharing status of objects, a user can
cause many ShareNotifications to be generated, which could lead to
resource exhaustion. Servers can mitigate this by coalescing
multiple changes to the same object into a single notification,
limiting the maximum number of notifications it stores per user and/
or rate-limiting the changes to sharing permissions in the first
place. Automatically deleting older notifications after reaching a
limit can mean the user is not made aware of a sharing change, which
can itself be a security issue. For this reason, it is better to
coalesce changes and use other mitigation strategies.
6.4. Unauthorized Principals
The set of Principals within a shared environment MUST be strictly
controlled. If adding a new Principal is open to the public, risks
include:
* An increased risk of a user accidentally sharing data with an
unintended person.
* An attacker sharing unwanted or offensive information with the
user.
* An attacker sharing items with spam content in the names in order
to generate ShareNotification objects, which are likely to be
prominently displayed to the user receiving them.
7. IANA Considerations
7.1. JMAP Capability Registration for "principals"
IANA has registered "principals" in the "JMAP Capabilities" registry
as follows:
Capability Name: urn:ietf:params:jmap:principals
Intended Use: common
Change Controller: IETF
Security and Privacy Considerations: RFC 9670, Section 6
Reference: RFC 9670
7.2. JMAP Capability Registration for "principals:owner"
IANA has registered "principals:owner" in the "JMAP Capabilities"
registry as follows:
Capability Name: urn:ietf:params:jmap:principals:owner
Intended Use: common
Change Controller: IETF
Security and Privacy Considerations: RFC 9670, Section 6
Reference: RFC 9670
7.3. JMAP Data Type Registration for "Principal"
IANA has registered "Principal" in the "JMAP Data Types" registry as
follows:
Type Name: Principal
Can Reference Blobs: No
Can Use for State Change: Yes
Capability: urn:ietf:params:jmap:principals
Reference: RFC 9670
7.4. JMAP Data Type Registration for "ShareNotification"
IANA has registered "ShareNotification" in the "JMAP Data Types"
registry as follows:
Type Name: ShareNotification
Can Reference Blobs: No
Can Use for State Change: Yes
Capability: urn:ietf:params:jmap:principals
Reference: RFC 9670
8. References
8.1. Normative References
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
Requirement Levels", BCP 14, RFC 2119,
DOI 10.17487/RFC2119, March 1997,
<https://www.rfc-editor.org/info/rfc2119>.
[RFC5322] Resnick, P., Ed., "Internet Message Format", RFC 5322,
DOI 10.17487/RFC5322, October 2008,
<https://www.rfc-editor.org/info/rfc5322>.
[RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC
2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174,
May 2017, <https://www.rfc-editor.org/info/rfc8174>.
[RFC8620] Jenkins, N. and C. Newman, "The JSON Meta Application
Protocol (JMAP)", RFC 8620, DOI 10.17487/RFC8620, July
2019, <https://www.rfc-editor.org/info/rfc8620>.
8.2. Informative References
[IANA-JMAP]
IANA, "JMAP Data Types",
<https://www.iana.org/assignments/jmap>.
[IANA-TZDB]
IANA, "Time Zone Database",
<https://www.iana.org/time-zones>.
[RFC8264] Saint-Andre, P. and M. Blanchet, "PRECIS Framework:
Preparation, Enforcement, and Comparison of
Internationalized Strings in Application Protocols",
RFC 8264, DOI 10.17487/RFC8264, October 2017,
<https://www.rfc-editor.org/info/rfc8264>.
Author's Address
Neil Jenkins (editor)
Fastmail
PO Box 234, Collins St West
Melbourne VIC 8007
Australia
Email: neilj@fastmailteam.com
URI: https://www.fastmail.com

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