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
## 1.5.1 (2026-04-25)
### Features
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
- **Mail**: Right-click context menu on the folders sidebar
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
- **Junk**: Setting to show avatars in the Junk folder (off by default)
### Fixes
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
- **Folders**: Stop flicker on background folder refresh
- **Email**: Preserve search/filter on batch move and archive
- **Email**: Preserve search/filter when moving emails via drag-drop
- **i18n**: Improve Korean flag
## 1.5.0 (2026-04-22)
### Breaking Changes
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
### Features
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
- **Admin**: Add API Keys management and IP allowlist for App Passwords
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
- **Contacts**: Add contact activity component showing recent emails and upcoming events
- **Contacts**: Add right-click context menu
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
- **Calendar**: Support resizing events from the top edge
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
- **Calendar**: Optimize layout of overlapping events
- **Calendar**: Add collapsible details to calendar invitation banner
- **Email**: Implement batch archiving and bulk moving of emails
- **Email**: Show full folder path in move/drop toast
- **Settings**: Reorganize settings into 6 groups with clearer tabs
- **Navigation**: Add account-addition button to the navigation rail
- **Mobile**: Streamline email viewer header layout
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
### Fixes
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
- **Mailbox**: Improve error message on mailbox creation failure
- **Auth**: Skip `checkAuth` on route change when already authenticated
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
- **UI**: Align hover styles and selection-toggle target with focused item
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
### Refactor
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
### Chore
- **i18n**: Add missing translation keys
- **Deps**: Bump dependencies to latest compatible versions
## 1.4.14 (2026-04-16)
Thank you for your donations:
+124
View File
@@ -0,0 +1,124 @@
# Features
## Mail
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts
- Draft auto-save with identity preservation
- Attachment upload, download, and inline preview; forgotten-attachment warning
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- Plain-text composer mode and Reply-To support
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer
- Browser history sync for back/forward navigation
## Calendar
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
- Recurring events with scoped edit/delete (this / this and following / all)
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
- Inline calendar invitations in the email viewer auto-detect `.ics`, RSVP, import
- iCalendar import with preview, bulk create, and UID deduplication
- iCal / webcal subscriptions with editing and batch import
- Auto-generated birthday calendar from contacts
- Virtual locations (video conference URLs) as first-class event fields
- Task management with due dates, priority, and completion status
- Shared calendars with CalDAV discovery and multi-account home resolution
- Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push
## Contacts
- JMAP sync (RFC 9553 / 9610) with local fallback
- Multiple address books with drag-and-drop between books
- Contact groups with member management
- vCard import/export (RFC 6350) with duplicate detection
- Trusted senders stored in a dedicated JMAP address book
- Autocomplete in the composer (To / Cc / Bcc)
## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
- Preserves rules authored in other clients
- Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
## Files
- JMAP FileNode browser (Stalwart native cloud storage)
- Streamed WebDAV PUT upload and folder upload with progress tracking
- Dynamic upload limits based on server configuration
- Grid and list views with sorting by name, size, or date
- Previews for images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
## Security & Privacy
- External content blocked by default, with a trusted senders list
- HTML sanitization via DOMPurify
- S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
- SPF / DKIM / DMARC status indicators
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- TOTP two-factor authentication
- Account security panel for password and 2FA management via the Stalwart admin API
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
- Plugin hardening with dangerous-pattern detection and admin approval
- Newsletter unsubscribe (RFC 2369)
## Interface
- Three-pane layout with resizable columns
- Dark and light themes with intelligent email color transformation
- Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation
- Drag-and-drop email organization and tag assignment
- Interactive guided tour for new users
- Right-click context menus, toast notifications with undo
- Customizable toolbar position, favicon, and login branding
- Pinnable sidebar apps with drag-and-drop reordering
- Encrypted settings sync across devices
- Storage quota display
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
## Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
## Identity & Multi-Account
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
- Account switcher with connection status and default account selection
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
- Shared folders across accounts
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
## Admin & Extensibility
- Stalwart admin dashboard with dedicated policy sections
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- Bundled plugins including Jitsi Meet calendar integration
## Operations
- Progressive Web App with service worker, install prompt, and dynamic manifest
- Automatic update check with server-side logging of new releases
- Structured logging (`text` or `json`) with category-based levels
- Release (`main`) and development (`dev`) Docker images on GHCR
- Demo mode with fixture data no mail server required
+69 -248
View File
@@ -8,12 +8,11 @@
# Bulwark Webmail
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
Built with Next.js and the JMAP protocol.
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.14-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.5.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)
</div>
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** - Three-pane layout with sidebar, email list, and viewer
</td>
<td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** - Month, week, day, and agenda views with event management
</td>
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
**Contacts** - Contact management with groups and vCard support
</td>
<td width="50%">
<img src="screenshots/files.png" width="100%" alt="File browser">
**Files** - Cloud file browser with upload, preview, and folder navigation
</td>
<td><sub><b>Calendar</b> month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
<td><sub><b>Contacts</b> multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
</tr>
<tr>
<td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</tr>
<tr>
<td><sub><b>Themes</b> bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<td><sub><b>Plugins</b> extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
<tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr>
</table>
<details>
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
## Overview
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
**Light mode** - Full theme support with intelligent color transformation
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
</td>
<td width="50%">
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
<img src="screenshots/settings.png" width="100%" alt="Settings">
**Settings** - Appearance, identities, filters, templates, and more
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/login.png" width="100%" alt="Login page">
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
</td>
<td width="50%">
</td>
</tr>
</table>
</details>
## Features
### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
- **Unified mailbox** - view emails across all accounts in a single list
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** - archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer
- **Answered/forwarded status icons** in email list and thread views
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
- **Star/unstar** with configurable mark-as-read delay
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
- **Sender avatars** - favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction
- **Plain text composer mode** and auto-select reply identity
- **Reply-to addresses** support in the composer
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
- **message/rfc822 unwrapping** for embedded messages
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
- **Tag counts** - unread and total counts displayed in sidebar
- **Browser history sync** - back/forward navigation mirrors mail view state
### Calendar
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
- **Event hover preview** popover with configurable details
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview, bulk create, and UID deduplication
- **iCal / webcal subscriptions** with editing and batch import
- **Birthday calendar** - auto-generated from contacts
- **Virtual locations** - video conference URLs as first-class event fields
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
- **Shared calendars** with visual grouping in the sidebar
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
- **Week numbers** in mini-calendar sidebar
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push
### Contacts
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
- **Collapsible sidebar** with address book grouping and bulk operations
- **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection
- **Trusted senders** stored in a dedicated JMAP address book
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** - multi-select, delete, group add, export
### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
- **External rule preservation** - rules authored in other clients are displayed and preserved
- **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Files
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
- **Dynamic upload limits** - respects the server-configured maximum upload size
- **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** - cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access
- **Bulk operations** - multi-select, delete, move, download
### Security & Privacy
- **External content blocked** by default - trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
- **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
- **TOTP two-factor authentication**
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
- **Newsletter unsubscribe** (RFC 2369)
### Interface
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation
- **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** - full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Interactive guided tour** - onboarding walkthrough for new users
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
- **Settings sync** - preferences synchronized with the server (encrypted)
- **Storage quota** display
- **Version badge** in settings
- **Focused mode** with proper viewport bounds
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
### Identity Management
- **Multiple sender identities** with per-identity signatures
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list
### Multi-Account
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
- **Account switcher** with connection status, default account selection, and per-account logout
- **Per-account settings** - encrypted settings storage with server-side sync
- **Shared folders** across accounts
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
### Admin & Extensibility
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- **Bundled plugins** - Jitsi Meet calendar integration
### Operations
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
- **Automatic update check** - server logs when a newer release is available
- **Logging categories** with `text` or `json` formats for log aggregation
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
Full feature list: **[FEATURES.md](FEATURES.md)**.
---
## Quick Start
### Docker (recommended)
### Docker
```bash
docker run -d -p 3000:3000 \
@@ -255,7 +82,7 @@ Or with Docker Compose:
```bash
cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL
# Edit .env.local set JMAP_SERVER_URL
docker compose up -d
```
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL
# Edit .env.local set JMAP_SERVER_URL
npm run build && npm start
```
### Development
```bash
npm run dev # Start dev server (mock JMAP server included)
npm run typecheck # Type checking
npm run lint # Linting
npm run dev # Dev server with a mock JMAP server
npm run typecheck
npm run lint
```
## Configuration
Edit `.env.local`:
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env
# Required
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail
```
All variables are **runtime** - Docker deployments can be configured without rebuilding.
<details>
<summary>Server Listen Address</summary>
<summary>Server listen address</summary>
```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000 # Default listen port
PORT=3000
```
</details>
<details>
<summary>OAuth2/OIDC (SSO)</summary>
<summary>OAuth2 / OIDC</summary>
```env
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
@@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details>
<details>
<summary>Remember Me & Settings Sync</summary>
<summary>Session & settings sync</summary>
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details>
<details>
<summary>Custom JMAP Endpoint</summary>
<summary>Custom JMAP endpoint</summary>
```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
ALLOW_CUSTOM_JMAP_ENDPOINT=true
```
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
</details>
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
```env
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail # Home-screen label on mobile
APP_DESCRIPTION=Your personal mail # Shown during PWA install
APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
</details>
<details>
<summary>Extension Directory</summary>
<summary>Extension directory</summary>
```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
@@ -382,13 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
</details>
<details>
<summary>Stalwart Integration & Logging</summary>
<summary>Stalwart integration & logging</summary>
```env
STALWART_FEATURES=true # Password change, sieve filters, etc.
STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug"
LOG_LEVEL=info # error | warn | info | debug
```
</details>
@@ -423,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[GNU AGPL v3](LICENSE)
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
## Acknowledgments
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
+1 -1
View File
@@ -1 +1 @@
1.4.14
1.5.1
+8 -1
View File
@@ -126,8 +126,15 @@ export default function CalendarPage() {
}
}, [events, detailEvent]);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
+39 -20
View File
@@ -98,13 +98,20 @@ export default function ContactsPage() {
// Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
});
const [isListResizing, setIsListResizing] = useState(false);
const listDragStartWidth = useRef(320);
const listDragStartWidth = useRef(384);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
@@ -172,21 +179,6 @@ export default function ContactsPage() {
return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]);
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all");
}
if ("keyword" in activeCategory) {
return activeCategory.keyword;
}
const group = contacts.find(c => c.id === activeCategory.groupId);
return group ? getContactDisplayName(group) : t("tabs.all");
}, [activeCategory, contacts, addressBooks, t]);
const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category);
clearSelection();
@@ -306,6 +298,24 @@ export default function ContactsPage() {
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) {
await createContact(client, data);
@@ -607,6 +617,16 @@ export default function ContactsPage() {
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
: undefined
}
onDuplicate={
selectedContact
? () => void handleDuplicateContact(selectedContact)
: undefined
}
isMobile={isMobile}
/>
);
@@ -702,7 +722,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
@@ -726,7 +745,7 @@ export default function ContactsPage() {
setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth));
}}
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
/>
)}
</>
+8 -1
View File
@@ -105,8 +105,15 @@ export default function FilesPage() {
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
+259 -32
View File
@@ -8,12 +8,13 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
@@ -23,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
@@ -35,6 +37,7 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
@@ -67,6 +70,7 @@ export default function Home() {
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
@@ -162,6 +166,11 @@ export default function Home() {
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
createMailbox,
renameMailbox,
deleteMailbox,
} = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -455,8 +464,15 @@ export default function Home() {
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
@@ -493,7 +509,10 @@ export default function Home() {
// Load mailboxes and emails when authenticated (only if not already loaded)
useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
const loadData = async () => {
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([
@@ -505,6 +524,15 @@ export default function Home() {
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
// On first login the server may still be provisioning mailboxes.
// Retry a few times with back-off before giving up.
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
// Fetch emails for the selected mailbox
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
@@ -538,6 +566,12 @@ export default function Home() {
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
};
}
// Cleanup push notifications on unmount
@@ -813,8 +847,11 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return;
// Find archive mailbox
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
const currentMailboxes = useEmailStore.getState().mailboxes;
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return;
const { archiveMode } = useSettingsStore.getState();
@@ -823,14 +860,12 @@ export default function Home() {
if (archiveMode === 'single') {
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
} else {
// Determine year/month from the email's received date
const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Find or create year subfolder under archive
let yearMailbox = mailboxes.find(
let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
@@ -841,9 +876,9 @@ export default function Home() {
if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else {
// archiveMode === 'month' - find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = mailboxes.find(
const afterYear = useEmailStore.getState().mailboxes;
let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
@@ -1071,6 +1106,197 @@ export default function Home() {
}
};
const tCtxMenu = t;
const handleMarkFolderRead = async (mailboxId: string) => {
if (!client) return;
try {
const count = await markMailboxAsRead(client, mailboxId);
await fetchMailboxes(client);
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
if (count > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkFolderTreeRead = async (mailboxId: string) => {
if (!client) return;
const collectIds = (rootId: string): string[] => {
const ids: string[] = [rootId];
const stack = [rootId];
while (stack.length > 0) {
const current = stack.pop()!;
for (const mb of mailboxes) {
if (mb.parentId === current) {
ids.push(mb.id);
stack.push(mb.id);
}
}
}
return ids;
};
try {
const ids = collectIds(mailboxId);
let total = 0;
for (const id of ids) {
total += await markMailboxAsRead(client, id);
}
await fetchMailboxes(client);
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkAllFoldersRead = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
variant: "default",
});
if (!confirmed) return;
try {
const total = await client.markAllAsRead();
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('email_list.empty_folder.confirm_title'),
message: tCtxMenu('email_list.empty_folder.confirm_message'),
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
try {
await emptyMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
}
};
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name, parentId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleCreateFolderFromContextMenu = async () => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_folder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.rename'),
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
defaultValue: mailbox.name,
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
});
if (!name || name === mailbox.name) return;
try {
await renameMailbox(client, mailboxId, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
}
};
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
if (jmapType === 'mailboxHasChild') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
} else if (jmapType === 'mailboxHasEmail') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
} else {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
}
}
};
const handleRefreshMailboxes = async () => {
if (!client) return;
try {
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
} catch {
// silent
}
};
const handleLogout = logout;
const handleSearch = async (query: string) => {
@@ -1269,15 +1495,11 @@ export default function Home() {
};
// Handle back navigation from viewer on mobile.
// Delegate to the browser history stack so this button is equivalent to
// the OS back button / mouse back button - popstate then restores the
// previous snapshot via handleNavRestore. The viewer is only reachable
// from a state that pushed history, so back() always lands on an app entry.
// Reset to list state directly. We can't just call window.history.back()
// because the nav hook pushes a new entry for every email the user opens,
// so history.back() would pop to the previous email rather than the list.
// The OS / hardware back button is still wired through popstate → handleNavRestore.
const handleMobileBack = () => {
if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) {
setConversationThread(null);
setConversationEmails([]);
@@ -1432,6 +1654,15 @@ export default function Home() {
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
onMarkFolderRead={handleMarkFolderRead}
onMarkFolderTreeRead={handleMarkFolderTreeRead}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={handleEmptyFolderFromContextMenu}
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
@@ -1492,9 +1723,12 @@ export default function Home() {
} else {
selectAllEmails();
}
} else {
// Enter selection mode by selecting the first email
if (emails.length > 0) toggleEmailSelection(emails[0].id);
} else if (emails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && emails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
toggleEmailSelection(target);
}
}}
className={cn(
@@ -1898,14 +2132,6 @@ export default function Home() {
/>
) : (
<>
{/* Mobile Header for Viewer */}
{isMobile && activeView === "viewer" && (
<MobileViewerHeader
subject={selectedEmail?.subject}
onBack={handleMobileBack}
/>
)}
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
@@ -1913,12 +2139,12 @@ export default function Home() {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onDelete={() => handleDelete()}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
onUndoSpam={handleUndoSpam}
onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
@@ -1988,6 +2214,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} />
<TotpReauthDialog />
</div>
</DragDropProvider>
+150 -80
View File
@@ -9,7 +9,6 @@ import {
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
@@ -20,17 +19,27 @@ import {
FolderOpen,
Tags,
HardDrive,
Wrench,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
LayoutGrid,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
import { ComposingSettings } from '@/components/settings/composing-settings';
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { AboutDataSettings } from '@/components/settings/about-data-settings';
import { DebugSettings } from '@/components/settings/debug-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
type Tab =
| 'account'
| 'language'
| 'notifications'
| 'appearance'
| 'layout'
| 'reading'
| 'composing'
| 'identities'
| 'vacation'
| 'filters'
| 'templates'
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
| 'files'
| 'sidebar_apps'
| 'about_data'
| 'themes'
| 'plugins'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
id: Tab;
@@ -74,28 +109,54 @@ interface TabDef {
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
notifications: Bell,
account: User,
security: Shield,
language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen,
encryption: KeyRound,
vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
plugins: Puzzle,
advanced: Wrench,
debug: Bug,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
email: 'reading',
advanced: 'about_data',
};
function readPersistedTab(): Tab {
try {
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
const migrated = LEGACY_TAB_MAP[saved];
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
} catch {
return 'appearance';
}
}
export default function SettingsPage() {
const router = useRouter();
@@ -107,13 +168,7 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
@@ -124,21 +179,28 @@ export default function SettingsPage() {
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Listen for tab change events from child components
// Listen for tab change events from child components (with legacy migration)
useEffect(() => {
const handler = (e: Event) => {
const tab = (e as CustomEvent).detail as Tab;
if (tab) {
const raw = (e as CustomEvent).detail as string;
if (!raw) return;
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
}
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
@@ -161,25 +223,41 @@ export default function SettingsPage() {
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
// General
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// Group tabs by category
@@ -191,6 +269,10 @@ export default function SettingsPage() {
}))
.filter((g) => g.items.length > 0);
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
@@ -199,39 +281,42 @@ export default function SettingsPage() {
}
};
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'notifications' && <NotificationSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'themes' && <ThemesSettings />}
{activeTab === 'plugins' && <PluginsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'templates' && <TemplateSettings />}
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile content header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -244,14 +329,12 @@ export default function SettingsPage() {
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -264,10 +347,8 @@ export default function SettingsPage() {
);
}
// Mobile: show tab list
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -283,7 +364,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{groupedTabs.map((group, groupIndex) => (
@@ -319,7 +399,6 @@ export default function SettingsPage() {
))}
</div>
{/* Logout */}
<div className="border-t border-border px-5 py-3">
<button
onClick={logout}
@@ -331,7 +410,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -347,7 +425,6 @@ export default function SettingsPage() {
// Desktop layout
return (
<div className="flex h-dvh bg-background">
{/* Navigation Rail */}
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -366,7 +443,6 @@ export default function SettingsPage() {
)}
{!inlineApp && (
<>
{/* Settings Sidebar */}
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
@@ -374,7 +450,6 @@ export default function SettingsPage() {
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{/* Header */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
@@ -387,7 +462,6 @@ export default function SettingsPage() {
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
@@ -406,14 +480,14 @@ export default function SettingsPage() {
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
{tab.experimental && (
@@ -430,7 +504,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Sidebar resize handle */}
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
@@ -441,10 +514,8 @@ export default function SettingsPage() {
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
@@ -452,7 +523,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
+166 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react';
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
@@ -69,6 +69,58 @@ export default function AdminAuthPage() {
}
}
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0;
if (loading) {
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
</div>
)}
{/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
</p>
</div>
<button
onClick={openSetupDialog}
disabled={setupRunning}
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</button>
</div>
</div>
{/* Auto-setup dialog */}
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable OAuth only (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<button
onClick={() => setSetupOpen(false)}
disabled={setupRunning}
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</button>
</div>
</div>
</div>
)}
{/* OAuth */}
<Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
+30 -21
View File
@@ -65,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
@@ -73,21 +74,15 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
if (pathname !== '/admin/login') {
checkAuth();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
function getJmapHeaders(): Record<string, string> {
return getActiveAccountSlotHeaders();
}
if (pathname === '/admin/login') return;
let cancelled = false;
async function checkAuth() {
try {
const jmapHeaders = getJmapHeaders();
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
@@ -110,18 +105,29 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
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 {
router.replace('/admin/login');
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router]);
async function handleLogout() {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
@@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>;
}
if (authenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */}
@@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6">
{children}
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div>
</main>
</div>
Binary file not shown.
+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,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number {
export async function POST(request: NextRequest) {
try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
if (oauthEnabled && oauthOnly) {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
}
+3 -2
View File
@@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -113,8 +114,8 @@ async function attemptAllStrategies(
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
+1 -2
View File
@@ -112,10 +112,9 @@ const emails: MockEmail[] = [
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }],
htmlBody: [],
bodyValues: {
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
p2: { value: '<p>Hallo!</p><p>This is a sample email to help you get started with the <strong>Bulwark Webmail</strong> development environment.</p><p>Feel free to explore the UI - all data here is mock data.</p><p>Beste Grüße,<br>Sophie</p>' },
},
},
{
+16 -3
View File
@@ -119,6 +119,7 @@ export function CalendarDayView({
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
isMobile,
});
const formatHour = (h: number): string => {
@@ -246,9 +247,11 @@ export function CalendarDayView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100;
@@ -270,11 +273,21 @@ export function CalendarDayView({
onContextMenu={onContextMenuEvent}
draggable
/>
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
+7 -4
View File
@@ -260,6 +260,9 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
@@ -279,14 +282,14 @@ export function CalendarToolbar({
<div className="flex-1" />
{!isMobile && (
<div className="flex border border-border rounded-md overflow-hidden">
<div className="flex h-8 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors",
"inline-flex items-center px-3 text-xs font-medium transition-colors",
v === viewMode
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground"
@@ -300,7 +303,7 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
@@ -331,7 +334,7 @@ export function CalendarToolbar({
)}
{!isMobile && (
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
+16 -3
View File
@@ -183,6 +183,7 @@ export function CalendarWeekView({
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
isMobile,
});
const formatHour = (h: number): string => {
@@ -384,9 +385,11 @@ export function CalendarWeekView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100;
@@ -408,11 +411,21 @@ export function CalendarWeekView({
onContextMenu={onContextMenuEvent}
draggable
/>
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
+16 -3
View File
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => {
setTimeout(() => {
setShowSuggestions(false);
const trimmed = query.trim();
if (trimmed && EMAIL_REGEX.test(trimmed)) {
addParticipant({ name: "", email: trimmed });
}
}, 200);
setTimeout(() => setShowSuggestions(false), 200);
}, [query, addParticipant]);
useImperativeHandle(ref, () => ({
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
key={`${p.email}-${i}`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
>
{!disabled ? (
<button
type="button"
onClick={() => {
onRemove(p.email);
setQuery(p.email);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
aria-label={`${t("edit")} ${p.name || p.email}`}
>
{p.name || p.email}
</button>
) : (
<span className="truncate">{p.name || p.email}</span>
)}
{!disabled && (
<button
type="button"
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const deleteButton = screen.getAllByRole('button').find(
btn => btn.className.includes('text-red')
);
fireEvent.click(deleteButton!);
fireEvent.click(screen.getByLabelText('detail.more_actions'));
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
expect(onDelete).toHaveBeenCalledOnce();
});
});
@@ -36,7 +36,6 @@ const defaultProps = {
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
categoryLabel: 'All Contacts',
selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(),
@@ -79,8 +78,4 @@ describe('ContactList', () => {
expect(screen.getByText('bulk.export')).toBeInTheDocument();
});
it('shows category label with count', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
});
});
+85 -59
View File
@@ -2,12 +2,13 @@
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Mail, CalendarDays, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5;
@@ -43,7 +44,7 @@ function buildEmailFilter(addresses: string[]): Record<string, unknown> {
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (!event.participants) return false;
if (event.participants) {
for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true;
@@ -54,6 +55,7 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
}
}
}
}
if (event.organizerCalendarAddress) {
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
@@ -62,6 +64,26 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
return false;
}
function getEmailSender(email: Email): { name: string; address: string } {
const from = email.from?.[0];
return {
name: from?.name?.trim() || from?.email || "",
address: from?.email || "",
};
}
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const groups = new Map<string, CalendarEvent[]>();
for (const e of events) {
const d = new Date(e.start);
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
const arr = groups.get(key) || [];
arr.push(e);
groups.set(key, arr);
}
return groups;
}
export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity");
const format = useFormatter();
@@ -170,111 +192,115 @@ export function ContactActivity({ contact }: ContactActivityProps) {
: { year: "numeric", month: "short", day: "numeric" });
};
const formatEventDate = (event: CalendarEvent) => {
const formatEventTime = (event: CalendarEvent) => {
if (event.showWithoutTime) return t("all_day");
const d = new Date(event.start);
if (isNaN(d.getTime())) return event.start;
if (event.showWithoutTime) {
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
}
return format.dateTime(d, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
if (isNaN(d.getTime())) return "";
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
};
const formatEventDateHeader = (isoDate: string) => {
const d = new Date(isoDate);
if (isNaN(d.getTime())) return isoDate;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { weekday: "long", month: "long", day: "numeric" }
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
};
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
return (
<>
<ActivitySection icon={Mail} title={t("recent_emails")}>
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<Section title={t("recent_emails")}>
{emailsLoading ? (
<LoadingRow />
) : emailsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !emails || emails.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
) : (
emails.map((email) => (
<div className="-mx-2">
{emails.map((email) => {
const sender = getEmailSender(email);
return (
<button
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<Avatar name={sender.name} email={sender.address} size="sm" />
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{email.subject || t("no_subject")}
{sender.name || t("unknown_sender")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
</span>
</div>
<div className="text-sm truncate">
{email.subject || t("no_subject")}
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</div>
</button>
))
);
})}
</div>
)}
</ActivitySection>
</Section>
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
<Section title={t("upcoming_events")}>
{eventsLoading ? (
<LoadingRow />
) : eventsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !events || events.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
) : (
events.map((event) => (
<div className="space-y-4">
{eventGroups.map(([dateKey, group]) => (
<div key={dateKey}>
<div className="text-xs font-medium text-muted-foreground mb-1.5">
{formatEventDateHeader(dateKey)}
</div>
<div className="-mx-2">
{group.map((event) => (
<button
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
<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>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEventDate(event)}
</span>
</div>
</button>
))
)}
</ActivitySection>
</>
);
}
function ActivitySection({
icon: Icon,
title,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
}) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
))}
</div>
<div className="space-y-1 pl-6">{children}</div>
</div>
))}
</div>
)}
</Section>
</div>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
);
}
+54 -7
View File
@@ -11,16 +11,25 @@ import {
Eye,
Pencil,
Mail,
Phone,
ClipboardCopy,
Download,
Users,
Trash2,
Copy,
Printer,
} from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
if (!contact.phones) return "";
return Object.values(contact.phones)[0]?.number || "";
}
interface Position {
x: number;
y: number;
@@ -38,6 +47,7 @@ interface ContactContextMenuProps {
onEdit: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onDuplicate?: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
@@ -55,12 +65,14 @@ export function ContactContextMenu({
onEdit,
onDelete,
onAddToGroup,
onDuplicate,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => {
@@ -73,10 +85,15 @@ export function ContactContextMenu({
window.location.href = `mailto:${email}`;
};
const handleCopyEmail = async () => {
if (!email) return;
const handleCall = () => {
if (!phone) return;
window.location.href = `tel:${phone}`;
};
const handleCopy = async (value: string) => {
if (!value) return;
try {
await navigator.clipboard.writeText(email);
await navigator.clipboard.writeText(value);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
@@ -88,6 +105,10 @@ export function ContactContextMenu({
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact);
};
if (showBatchActions) {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
@@ -122,20 +143,34 @@ export function ContactContextMenu({
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
)}
{phone && (
<ContextMenuItem
icon={Phone}
label={t("context_menu.call")}
onClick={handle(handleCall)}
/>
)}
{email && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(handleCopyEmail)}
onClick={handle(() => handleCopy(email))}
/>
)}
{phone && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_phone")}
onClick={handle(() => handleCopy(phone))}
/>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
@@ -143,11 +178,23 @@ export function ContactContextMenu({
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
onClick={handle(handleExport)}
/>
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
+365 -224
View File
@@ -1,23 +1,37 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
isMobile?: boolean;
className?: string;
}
@@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
const m = month ?? 1;
const d = day ?? 1;
const nowM = now.getMonth() + 1;
const nowD = now.getDate();
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
if (years < 0) return null;
return years;
}
function formatDate(dateInput: AnniversaryDate): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try {
@@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
return String(dateInput.utc);
}
const pd = dateInput as PartialDate;
const year = pd.year;
const month = pd.month;
const day = pd.day;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
if (day) parts.push(String(day));
if (year) parts.push(String(year));
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (pd.day) parts.push(String(pd.day));
if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
@@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
@@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
// data URI - extract base64 content
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
@@ -97,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
// PEM-encoded certificate inline
derBytes = key.uri;
}
if (!derBytes) continue;
@@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const photoUri = getContactPhotoUri(contact);
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact, name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
@@ -171,56 +237,67 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="flex items-center gap-4 min-w-0 flex-1">
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
{titleLine && (
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
)}
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
{subtitleParts.length > 0 && (
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
)}
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && (
<a
href={`mailto:${email}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
{t("detail.compose_email")}
</a>
)}
{phone && (
<a
href={`tel:${phone}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Phone className="w-4 h-4 mr-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
<Trash2 className="w-4 h-4" />
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
</div>
</div>
</div>
<div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<ContactActivity contact={contact} />
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
{hasContactDetails && (
<Section title={t("detail.section_contact")}>
<div className="space-y-3">
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
{e.address}
</a>
{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"
)}>
<RowActions>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
@@ -230,93 +307,62 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</div>
</FieldRow>
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => {
const featureStr = formatPhoneFeatures(p.features);
const features = formatPhoneFeatures(p.features);
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
return (
<div key={i} className="flex items-center gap-2 group">
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && <ContextBadge contexts={p.contexts} />}
{featureStr && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
)}
<CopyButton
value={p.number}
label={t("detail.copy_phone")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
<RowActions>
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
);
})}
</Section>
)}
{(roles.length > 0 || jobTitles.length > 1) && (
<Section icon={Briefcase} title={t("detail.titles")} category="work">
{titles.map((tl, i) => (
<div key={i} className="text-sm flex items-center gap-2">
<span>{tl.name}</span>
{tl.kind && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
)}
</div>
{addresses.map((a, i) => {
const lines: string[] = [];
if (a.full || a.fullAddress) {
lines.push((a.full || a.fullAddress) as string);
} else if (a.components && a.components.length > 0) {
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
if (joined) lines.push(joined);
} else {
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
.map(s => (typeof s === "string" ? s.trim() : ""))
.filter(Boolean) as string[];
lines.push(...parts);
}
return (
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
<div className="text-sm space-y-0.5">
{lines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</Section>
)}
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")} category="work">
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</Section>
)}
{/* Addresses span full width */}
{addresses.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={MapPin} title={t("detail.addresses")} category="location">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{addresses.map((a, i) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{a.full || a.fullAddress
? (a.full || a.fullAddress)
: a.components && a.components.length > 0
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && <ContextBadge contexts={a.contexts} />}
</div>
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)}
</div>
))}
</div>
</Section>
</div>
)}
</FieldRow>
);
})}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
<FieldRow
key={`os${i}`}
icon={Globe}
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
>
<div className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
@@ -324,76 +370,98 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
)}
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
<CopyButton
value={svc.user || svc.uri}
label={t("detail.copy_url")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
<RowActions>
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
))}
</div>
</Section>
)}
{anniversaries.length > 0 && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{formatDate(ann.date)}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{t(`detail.anniversary_${ann.kind}`)}
</span>
</div>
))}
</Section>
)}
{personalInfo.length > 0 && (
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
{personalInfo.map((pi, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{pi.value}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
{pi.level && (
<span className="text-xs text-muted-foreground">({pi.level})</span>
)}
</div>
))}
</Section>
)}
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
{hasWork && (
<Section title={t("detail.section_work")}>
<div className="space-y-3">
{orgs.map((o, i) => (
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
<div className="text-sm">
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
{contact.speakToAs.pronouns && (() => {
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
</FieldRow>
))}
{titles.map((tl, i) => (
<FieldRow
key={`tl${i}`}
icon={Briefcase}
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
>
<div className="text-sm">{tl.name}</div>
</FieldRow>
))}
</div>
</Section>
)}
{hasPersonal && (
<Section title={t("detail.section_personal")}>
<div className="space-y-3">
{anniversaries.map((ann, i) => {
const years = getCompletedYears(ann.date);
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
return (
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
<div className="text-sm">
{formatDate(ann.date)}
{years !== null && (
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)}
</div>
</FieldRow>
);
})}
{hasGender && (
<FieldRow icon={UserCircle} label={t("detail.gender")}>
<div className="text-sm">
{contact.speakToAs?.grammaticalGender && (
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
)}
{contact.speakToAs?.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</Section>
</FieldRow>
)}
{preferredLanguages.length > 0 && (
<Section icon={Languages} title={t("detail.languages")} category="personal">
{preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{lang.language}</span>
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
</div>
<FieldRow
key={`lg${i}`}
icon={Languages}
label={formatContexts(lang.contexts) || t("detail.language_label")}
>
<div className="text-sm">{lang.language}</div>
</FieldRow>
))}
{personalInfo.map((pi, i) => (
<FieldRow
key={`pi${i}`}
icon={Heart}
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
>
<div className="text-sm">{pi.value}</div>
</FieldRow>
))}
</div>
</Section>
)}
{keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital">
<Section title={t("detail.categories")}>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
@@ -405,23 +473,23 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
)}
{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) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{uri}</span>
{relType && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
<div className="text-sm break-all">{uri}</div>
</FieldRow>
);
})}
</div>
</Section>
)}
{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) => {
const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
@@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
: false;
return (
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
@@ -451,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button
variant="ghost"
size="sm"
className="ml-4 mt-1"
onClick={() => handleImportContactCert(i)}
>
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
@@ -466,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
)}
</>
) : (
<div className="text-sm break-all">
<div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
@@ -479,88 +543,165 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</div>
);
})}
</div>
</Section>
)}
{(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 && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.calendarUri}
</a>
</FieldRow>
)}
{contact.schedulingUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.schedulingUri}
</a>
</FieldRow>
)}
{contact.freeBusyUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.freeBusyUri}
</a>
</FieldRow>
)}
</div>
</Section>
)}
{/* Notes span full width */}
{notes.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
<Section title={t("detail.notes")}>
<div className="flex items-start gap-3">
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="text-sm space-y-2 flex-1 min-w-0">
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
))}
</Section>
</div>
</div>
</Section>
)}
{/* Timestamps span full width */}
<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">
<div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
</div>
</div>
);
}
function formatContexts(contexts?: Record<string, boolean>): string {
if (!contexts) return "";
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
}
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn("py-6", className)}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
{children}
</section>
);
}
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
{children}
</div>
</div>
);
}
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 }) {
function RowActions({ children }: { children: React.ReactNode }) {
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 className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{children}
</div>
);
}
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
const labels = Object.keys(contexts).filter(k => contexts[k]);
if (labels.length === 0) return null;
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 (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
{labels.join(", ")}
</span>
<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>
);
}
@@ -575,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
toast.error(failMsg);
}
}}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
title={label}
aria-label={label}
>
+169 -52
View File
@@ -2,11 +2,12 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
interface EmailEntry {
address: string;
@@ -53,55 +54,73 @@ interface ContactFormProps {
onCancel: () => void;
}
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const formCategoryStyles: Record<FormCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
category?: FormCategory;
}) {
const [open, setOpen] = useState(defaultOpen || !collapsible);
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
<section className="py-5">
<button
type="button"
className={cn(
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
collapsible && "hover:text-muted-foreground cursor-pointer",
!collapsible && "cursor-default"
"flex items-center gap-2 w-full text-left",
collapsible ? "cursor-pointer" : "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1}
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-left">{title}</span>
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
{open && (
<div className="space-y-3 pt-3 pb-1">
{(open || !collapsible) && (
<div className="space-y-3 mt-3">
{children}
</div>
)}
</div>
</section>
);
}
const MAX_PHOTO_DIM = 512;
const PHOTO_QUALITY = 0.85;
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * ratio));
const h = Math.max(1, Math.round(img.height * ratio));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("canvas-unsupported"));
return;
}
ctx.drawImage(img, 0, 0, w, h);
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
resolve({ uri, mediaType: "image/jpeg" });
};
img.onerror = () => reject(new Error("invalid-image"));
img.src = reader.result as string;
};
reader.onerror = () => reject(new Error("read-failed"));
reader.readAsDataURL(file);
});
}
function Select({ value, onChange, children, className }: {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
@@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}, [contact]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
}
return null;
}, [contact]);
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
const [photoError, setPhotoError] = useState<string | null>(null);
const [photoUploading, setPhotoUploading] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setPhotoError(t("photo_invalid"));
return;
}
if (file.size > 10 * 1024 * 1024) {
setPhotoError(t("photo_too_large"));
return;
}
setPhotoError(null);
setPhotoUploading(true);
try {
const { uri, mediaType } = await processImageFile(file);
setPhotoUri(uri);
setPhotoMediaType(mediaType);
} catch {
setPhotoError(t("photo_invalid"));
} finally {
setPhotoUploading(false);
}
};
const handlePhotoRemove = () => {
setPhotoUri(undefined);
setPhotoMediaType(undefined);
setPhotoError(null);
};
const validateEmail = (address: string): boolean => {
if (!address.trim()) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
@@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
});
}
const mediaMap: Record<string, ContactMedia> = {};
if (contact?.media) {
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind !== "photo") mediaMap[key] = m;
}
}
if (photoUri) {
const photoKey = initialPhotoEntry?.key || "photo";
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
}
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
@@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
@@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}
};
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
@@ -478,19 +556,66 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4">
<div className="px-6 py-4 max-w-3xl">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="flex items-center gap-4 pb-4">
<button
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
title={t("upload_photo")}
aria-label={t("upload_photo")}
>
<Avatar
name={previewName || undefined}
email={previewEmail || undefined}
contactPhotoUri={photoUri}
size="lg"
className="!w-20 !h-20 !text-xl"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
>
<Camera className="w-5 h-5" />
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
</span>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoSelect}
/>
</button>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
{photoError && (
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
)}
{photoUri && (
<button
type="button"
onClick={handlePhotoRemove}
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-3 h-3" />
{t("remove_photo")}
</button>
)}
</div>
</div>
<div className="divide-y divide-border/60">
{/* Address Book Selector */}
{addressBooks && addressBooks.length > 1 && (
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
@@ -504,12 +629,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
))}
</select>
</FormSection>
</div>
)}
{/* Name & Identity - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<FormSection icon={User} title={t("section_identity")}>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</div>
</FormSection>
</div>
{/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
<FormSection icon={Mail} title={t("email")}>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
<FormSection icon={Phone} title={t("phone")}>
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Addresses - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
{/* Addresses */}
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</Button>
</div>
</FormSection>
</div>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
<div className="space-y-2">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2">
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Notes - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
{/* Notes */}
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -876,7 +994,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</FormSection>
</div>
</div>
</div>
+400 -47
View File
@@ -1,18 +1,98 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
type TriState = boolean | null;
interface ListFilters {
organization: string;
jobTitle: string;
location: string;
emailDomain: string;
birthdayMonth: number | null;
hasEmail: TriState;
hasPhone: TriState;
hasPhoto: TriState;
}
const EMPTY_FILTERS: ListFilters = {
organization: "",
jobTitle: "",
location: "",
emailDomain: "",
birthdayMonth: null,
hasEmail: null,
hasPhone: null,
hasPhoto: null,
};
function cycleTri(v: TriState): TriState {
return v === null ? true : v === true ? false : null;
}
function countActiveFilters(f: ListFilters): number {
let n = 0;
if (f.organization.trim()) n++;
if (f.jobTitle.trim()) n++;
if (f.location.trim()) n++;
if (f.emailDomain.trim()) n++;
if (f.birthdayMonth !== null) n++;
if (f.hasEmail !== null) n++;
if (f.hasPhone !== null) n++;
if (f.hasPhoto !== null) n++;
return n;
}
function matchTri(actual: boolean, filter: TriState): boolean {
if (filter === null) return true;
return actual === filter;
}
function getAnniversaryMonth(date: AnniversaryDate): number | null {
if (typeof date === "string") {
const iso = date.match(/^(\d{4})-(\d{2})/);
if (iso) return parseInt(iso[2], 10);
const partial = date.match(/^--(\d{2})/);
if (partial) return parseInt(partial[1], 10);
return null;
}
if ("month" in date && date.month) return date.month;
if ("utc" in date && date.utc) {
const d = new Date(date.utc);
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
}
return null;
}
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
value === true && "bg-primary/10 border-primary/30 text-primary",
value === false && "bg-muted border-border text-muted-foreground line-through",
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
)}
>
{icon}
{label}
</button>
);
}
interface ContactListProps {
contacts: ContactCard[];
selectedContactId: string | null;
@@ -20,7 +100,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
categoryLabel: string;
className?: string;
selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void;
@@ -42,7 +121,6 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
categoryLabel,
className,
selectedContactIds,
onToggleSelection,
@@ -57,31 +135,94 @@ export function ContactList({
onAddContactToGroup,
}: ContactListProps) {
const t = useTranslations("contacts");
const locale = useLocale();
const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
const monthNames = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
}, [locale]);
const filtered = useMemo(() => {
if (!searchQuery) return contacts;
const lower = searchQuery.toLowerCase();
const lower = searchQuery.trim().toLowerCase();
const orgLower = filters.organization.trim().toLowerCase();
const jobLower = filters.jobTitle.trim().toLowerCase();
const locLower = filters.location.trim().toLowerCase();
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
return contacts.filter((c) => {
const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails
? Object.values(c.emails).map((e) => e.address.toLowerCase())
: [];
const phones = c.phones
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
: [];
const org = c.organizations
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
: [];
return (
name.includes(lower) ||
emails.some((e) => e.includes(lower)) ||
phones.some((p) => p.includes(lower)) ||
org.some((o) => o.includes(lower))
);
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;
});
}, [contacts, searchQuery]);
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();
if (name.includes(lower)) return true;
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
return false;
});
}, [contacts, searchQuery, filters]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
@@ -93,29 +234,214 @@ export function ContactList({
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
// Group sorted contacts by first letter of display name. Non-letter
// starters (digits, symbols, empty) collect under "#" which sorts last.
const groupedSections = useMemo(() => {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
const groups = new Map<string, ContactCard[]>();
for (const contact of sorted) {
const name = getContactDisplayName(contact).trim();
const first = name.charAt(0);
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
? first.toLocaleUpperCase(locale)
: "#";
const bucket = groups.get(letter);
if (bucket) bucket.push(contact);
else groups.set(letter, [contact]);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
if (a === "#") return 1;
if (b === "#") return -1;
return collator.compare(a, b);
})
.map(([letter, items]) => ({ letter, items }));
}, [sorted, locale]);
const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Search header */}
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground truncate">
{categoryLabel} ({contacts.length})
</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
{/* Toolbar: select / search / filter */}
<div className="border-b border-border bg-background">
<div className="px-3 py-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
if (hasSelection) {
if (allSelected) onClearSelection();
else onSelectAll(sortedIds);
} else if (sortedIds.length > 0) {
const target = selectedContactId && sortedIds.includes(selectedContactId)
? selectedContactId
: sortedIds[0];
onToggleSelection(target);
}
}}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
hasSelection
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
>
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8 h-8 text-sm"
className={cn("pl-9 h-9", searchQuery && "pr-8")}
/>
{searchQuery && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
filtersOpen || activeFilters > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("filters.toggle")}
aria-label={t("filters.toggle")}
>
<Filter className="w-4 h-4" />
{!filtersOpen && activeFilters > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{activeFilters}
</span>
)}
</button>
</div>
</div>
</div>
{filtersOpen && (
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
{t("filters.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setFiltersOpen(false)}
className="h-7 w-7"
aria-label={t("filters.close")}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
<Input
value={filters.organization}
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
placeholder={t("filters.organization_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
<Input
value={filters.jobTitle}
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
placeholder={t("filters.job_title_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
<Input
value={filters.location}
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
placeholder={t("filters.location_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
<Input
value={filters.emailDomain}
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
placeholder={t("filters.email_domain_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
<select
value={filters.birthdayMonth ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
aria-label={t("filters.birthday_month")}
>
<option value="">{t("filters.any_month")}</option>
{monthNames.map((name, i) => (
<option key={i} value={i + 1}>{name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ToggleChip
icon={<Mail className="w-3.5 h-3.5" />}
label={t("filters.has_email")}
value={filters.hasEmail}
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
/>
<ToggleChip
icon={<Phone className="w-3.5 h-3.5" />}
label={t("filters.has_phone")}
value={filters.hasPhone}
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
/>
<ToggleChip
icon={<ImageIcon className="w-3.5 h-3.5" />}
label={t("filters.has_photo")}
value={filters.hasPhoto}
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
/>
</div>
</div>
</div>
)}
{/* Bulk action bar */}
{hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
@@ -166,19 +492,32 @@ export function ContactList({
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery ? (
{searchQuery || activeFilters > 0 ? (
<>
{activeFilters > 0 && !searchQuery ? (
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
) : (
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => onSearchChange("")}
>
)}
<p className="text-sm font-medium text-foreground">
{searchQuery ? t("empty_search") : t("empty_filtered")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
</p>
<div className="flex gap-2 mt-3">
{searchQuery && (
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
{t("clear_search")}
</Button>
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
{t("filters.clear")}
</Button>
)}
</div>
</>
) : (
<>
@@ -193,8 +532,8 @@ export function ContactList({
)}
</div>
) : (
<div>
{sorted.map((contact) => (
(() => {
const renderItem = (contact: ContactCard) => (
<ContactListItem
key={contact.id}
contact={contact}
@@ -221,8 +560,22 @@ export function ContactList({
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
/>
);
return groupByLetter ? (
<div>
{groupedSections.map(({ letter, items }) => (
<section key={letter}>
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
{letter}
</div>
{items.map(renderItem)}
</section>
))}
</div>
) : (
<div>{sorted.map(renderItem)}</div>
);
})()
)}
</div>
+120
View File
@@ -0,0 +1,120 @@
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function printContact(contact: ContactCard, displayName?: string): void {
const printWindow = window.open("", "_blank");
if (!printWindow) return;
const name = displayName || getContactDisplayName(contact);
const photoUri = getContactPhotoUri(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const notes = contact.notes ? Object.values(contact.notes) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const rows: string[] = [];
const section = (title: string, items: string[]) => {
if (items.length === 0) return;
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
};
const row = (label: string, value: string) =>
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
section(
"Organization",
orgs.map(o => {
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
return row("Organization", value);
}),
);
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
section(
"Address",
addresses.map(a => {
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
.map(p => (typeof p === "string" ? p : ""))
.filter(Boolean);
return row(a.label || "Address", parts.join(", "));
}),
);
section(
"Online",
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
);
section(
"Anniversary",
anniversaries.map(a => {
const date = a.date;
let formatted = "";
if (typeof date === "object" && date !== null) {
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
else {
const parts: string[] = [];
const pd = date as PartialDate;
if (pd.year) parts.push(String(pd.year));
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
formatted = parts.join("-");
}
} else if (date) {
formatted = String(date);
}
return row(a.kind || "Date", formatted);
}),
);
section("Notes", notes.map(n => row("", n.note || "")));
const photoTag = photoUri
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(name || "Contact")}</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
h1 { margin: 0; font-size: 22px; }
section { margin-top: 20px; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
dt { color: #555; }
dd { margin: 0; word-break: break-word; }
@media print { body { margin: 16mm; } }
</style>
</head>
<body>
<header>
${photoTag}
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
</header>
${rows.join("")}
</body>
</html>`;
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
printWindow.print();
}
@@ -15,6 +15,7 @@ import {
X,
AlertCircle,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
const [isCollapsed, setIsCollapsed] = useState(true);
const attachment = findCalendarAttachment(email);
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const canCollapse = method === 'reply';
const showDetails = !canCollapse || !isCollapsed;
const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
</div>
{canCollapse && (
<button
type="button"
onClick={() => setIsCollapsed((prev) => !prev)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : t('collapse')}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
)}
</div>
{/* Content */}
{showDetails && (
<div className="px-4 py-3 space-y-2.5">
<div className="lg:flex lg:gap-6">
{/* Left: Event info */}
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div>
)}
</div>
)}
{/* Actions */}
{showDetails && (
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
{canRespond && (
<>
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)}
</div>
)}
</div>
);
}
+6 -2
View File
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
@@ -99,7 +101,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
@@ -163,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
)}
@@ -302,7 +306,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
+6 -5
View File
@@ -69,6 +69,7 @@ export function EmailList({
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
batchArchive,
batchMarkAsSpam,
batchUndoSpam,
loadMoreEmails,
@@ -497,12 +498,12 @@ export function EmailList({
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
if (!onArchive) return;
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
for (const email of selected) {
await onArchive(email);
if (!client) return;
try {
await batchArchive(client);
} catch (error) {
console.error('Failed to batch archive:', error);
}
clearSelection();
}}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
+117 -74
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -2322,7 +2322,7 @@ export function EmailViewer({
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
// Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -2438,7 +2438,7 @@ export function EmailViewer({
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (smimeDecryptedText) {
@@ -2446,7 +2446,7 @@ export function EmailViewer({
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG);
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (tnefText) {
@@ -2454,7 +2454,7 @@ export function EmailViewer({
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (embeddedEmailText) {
@@ -2603,7 +2603,7 @@ export function EmailViewer({
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2880,7 +2880,7 @@ export function EmailViewer({
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -3436,12 +3436,24 @@ export function EmailViewer({
moreMenuOpen ? "translate-x-0" : "translate-x-full"
)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
{moreMenuSub ? (
<button
onClick={() => setMoreMenuSub(null)}
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
<ChevronLeft className="w-5 h-5" />
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
</button>
) : (
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
)}
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto py-2">
{moreMenuSub === null && (
<>
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
@@ -3449,79 +3461,35 @@ export function EmailViewer({
<Archive className="w-5 h-5" />
{t('archive')}
</button>
{/* Move to folder */}
{/* Move to folder (opens sub-view) */}
{moveTree.length > 0 && onMoveToMailbox && (
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
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"
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
<FolderInput className="w-5 h-5" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
<div className="h-px bg-border my-1" />
</>
)}
{/* Tags */}
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
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"
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); 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 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
<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>
)}
<div className="h-px bg-border my-1" />
</>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
@@ -3592,6 +3560,70 @@ export function EmailViewer({
{t('keyboard_shortcuts')}
</button>
)}
</>
)}
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
</>
)}
</div>
</div>
)}
@@ -3628,7 +3660,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -3676,8 +3708,8 @@ export function EmailViewer({
)}
</div>
</div>
{/* Date/time on the right of subject row */}
<div className="flex-shrink-0 text-right">
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-right">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
@@ -4484,6 +4516,17 @@ export function EmailViewer({
)}
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
+12 -4
View File
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
@@ -133,7 +135,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "bg-background"
),
selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
@@ -181,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
)}
@@ -323,7 +327,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
@@ -358,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus';
@@ -375,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail,
@@ -487,7 +493,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
@@ -561,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={avatarPerson?.email}
size="md"
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
)}
@@ -725,7 +733,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
CheckCheck,
MailOpen,
Mails,
Trash2,
FolderPlus,
Pencil,
FolderX,
RefreshCw,
} from "lucide-react";
interface Position {
x: number;
y: number;
}
export type MailboxContextTarget =
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
| { kind: "folders-section" };
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefresh?: () => void;
}
export function MailboxContextMenu({
target,
position,
isOpen,
onClose,
menuRef,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const handleAction = (action: () => void) => {
action();
onClose();
};
if (!target) return null;
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(onCreateFolder!)}
disabled={!onCreateFolder}
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
const mailbox = target.mailbox;
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
destructive
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
+5 -6
View File
@@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
@@ -336,9 +335,9 @@ export function NavigationRail({
);
})}
{/* Admin (Stalwart admins) */}
{/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && (
<NextLink
<a
href="/admin"
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
@@ -348,7 +347,7 @@ export function NavigationRail({
>
<Shield className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink>
</a>
)}
{/* Settings */}
@@ -514,13 +513,13 @@ export function NavigationRail({
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && (
<NextLink
<a
href="/admin"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title={t("admin") || "Admin"}
>
<Shield className="w-[18px] h-[18px]" />
</NextLink>
</a>
)}
<Link
+64 -1
View File
@@ -31,6 +31,8 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
@@ -56,6 +58,15 @@ interface SidebarProps {
onCompose?: () => void;
onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
className?: string;
}
@@ -187,6 +198,7 @@ interface SidebarRowProps {
dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
}
function SidebarRow({
@@ -206,6 +218,7 @@ function SidebarRow({
dropHandlers,
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -213,6 +226,7 @@ function SidebarRow({
return (
<div
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -365,6 +379,7 @@ function MailboxTreeItem({
isCollapsed,
onUnreadFilterClick,
colorful,
onContextMenu,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -374,6 +389,7 @@ function MailboxTreeItem({
isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
colorful: boolean;
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) {
const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0;
@@ -423,6 +439,7 @@ function MailboxTreeItem({
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
@@ -436,6 +453,7 @@ function MailboxTreeItem({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful}
onContextMenu={onContextMenu}
/>
))}
</>
@@ -610,6 +628,15 @@ export function Sidebar({
onCompose: _onCompose,
onSidebarClose,
onUnreadFilterClick,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefreshMailboxes,
className,
}: SidebarProps) {
const router = useRouter();
@@ -790,6 +817,23 @@ export function Sidebar({
router.push('/settings');
};
const {
contextMenu: mailboxContextMenu,
openContextMenu: openMailboxContextMenu,
closeContextMenu: closeMailboxContextMenu,
menuRef: mailboxMenuRef,
} = useContextMenu<MailboxContextTarget>();
const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => {
const mailbox = mailboxes.find(mb => mb.id === node.id);
if (!mailbox) return;
openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 });
};
const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => {
openMailboxContextMenu(e, { kind: "folders-section" });
};
return (
<div
className={cn(
@@ -866,7 +910,7 @@ export function Sidebar({
</div>
)}
<div>
<div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader
label={t("folders")}
expanded={foldersExpanded}
@@ -894,6 +938,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))
)}
@@ -934,6 +979,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
</div>
@@ -975,6 +1021,23 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
<MailboxContextMenu
target={mailboxContextMenu.data}
position={mailboxContextMenu.position}
isOpen={mailboxContextMenu.isOpen}
onClose={closeMailboxContextMenu}
menuRef={mailboxMenuRef}
onMarkFolderRead={onMarkFolderRead}
onMarkFolderTreeRead={onMarkFolderTreeRead}
onMarkAllFoldersRead={onMarkAllFoldersRead}
onEmptyFolder={onEmptyFolder}
onCreateSubfolder={onCreateSubfolder}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onRefresh={onRefreshMailboxes}
/>
</div>
);
}
@@ -7,22 +7,21 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() {
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -86,7 +85,6 @@ export function AdvancedSettings() {
return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
@@ -123,50 +121,12 @@ export function AdvancedSettings() {
</div>
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
)}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Sender Favicons (Experimental) */}
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Export Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
@@ -175,7 +135,6 @@ export function AdvancedSettings() {
</SettingItem>
)}
{/* Import Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
@@ -193,7 +152,6 @@ export function AdvancedSettings() {
</SettingItem>
)}
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
+11 -72
View File
@@ -2,15 +2,13 @@
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { useSettingsStore, type Density } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -66,16 +64,15 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup
value={theme}
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Language */}
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
{/* Font Size */}
{!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Density */}
{!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
{/* Toolbar Labels */}
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch
@@ -194,7 +126,14 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Restart Tour */}
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
</SettingItem>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button
variant="outline"
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [newKeyword, setNewKeyword] = useState('');
const {
autoSelectReplyIdentity,
attachmentReminderEnabled,
attachmentReminderKeywords,
updateSetting,
} = useSettingsStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
return (
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection>
);
}
+14 -1
View File
@@ -4,11 +4,12 @@ import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section";
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
export function ContactsSettings() {
@@ -20,6 +21,8 @@ export function ContactsSettings() {
supportsSync,
importContacts,
} = useContactStore();
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group");
@@ -55,6 +58,16 @@ export function ContactsSettings() {
title={tSettings("title")}
description={tSettings("description")}
>
<SettingItem
label={tSettings("group_by_letter_label")}
description={tSettings("group_by_letter_description")}
>
<ToggleSwitch
checked={groupContactsByLetter}
onChange={(checked) => updateSetting("groupContactsByLetter", checked)}
/>
</SettingItem>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
@@ -0,0 +1,81 @@
"use client";
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
export function ContentSendersSettings() {
const t = useTranslations('settings.email_behavior');
const [showTrustedModal, setShowTrustedModal] = useState(false);
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const {
externalContentPolicy,
emailAlwaysLightMode,
trustedSenders,
trustedSendersAddressBook,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
};
return (
<SettingsSection title={t('title')} description={t('description')}>
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
)}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
<ToggleSwitch
checked={emailAlwaysLightMode}
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
/>
</SettingItem>
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
onClick={() => setShowTrustedModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</SettingItem>
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
<TrustedSendersModal
isOpen={showTrustedModal}
onClose={() => setShowTrustedModal(false)}
/>
</SettingsSection>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore, ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { usePolicyStore } from '@/stores/policy-store';
export function DebugSettings() {
const t = useTranslations('settings.advanced');
const { debugMode, debugCategories, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
if (isSettingHidden('debugMode') || !isFeatureEnabled('debugModeEnabled')) {
return (
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
<p className="text-sm text-muted-foreground py-2">{t('debug_mode.description')}</p>
</SettingsSection>
);
}
return (
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
{debugMode && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
</SettingsSection>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem } from './settings-section';
export function LanguageSettings() {
const t = useTranslations('settings.appearance');
return (
<SettingsSection title={t('language.label')} description={t('language.description')}>
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
</SettingsSection>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
{!isSettingHidden('mailLayout') && (
<SettingItem label={tEmail('mail_layout.label')} description={tEmail('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: tEmail('mail_layout.split') },
{ value: 'focus', label: tEmail('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={tEmail} />
</div>
</SettingItem>
)}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -1,118 +1,23 @@
"use client";
import { useState, useCallback } from 'react';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() {
export function ReadingSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [showTrustedModal, setShowTrustedModal] = useState(false);
const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
const [newKeyword, setNewKeyword] = useState('');
const {
markAsReadDelay,
deleteAction,
@@ -120,33 +25,17 @@ export function EmailSettings() {
showPreview,
mailLayout,
disableThreading,
autoSelectReplyIdentity,
plainTextMode,
emailsPerPage,
externalContentPolicy,
mailAttachmentAction,
attachmentPosition,
emailAlwaysLightMode,
archiveMode,
hoverActions,
hoverActionsMode,
hoverActionsCorner,
trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
hideInlineImageAttachments,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
};
const isFocusedLayout = mailLayout === 'focus';
@@ -163,8 +52,6 @@ export function EmailSettings() {
try {
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Fetch all emails in the root archive mailbox
const emails = await client.getEmailsInMailbox(archiveId);
let movedCount = 0;
@@ -173,10 +60,8 @@ export function EmailSettings() {
const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
// Re-read mailboxes from store each iteration in case new ones were created
let currentMailboxes = useEmailStore.getState().mailboxes;
// Find or create year subfolder
let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId
);
@@ -190,7 +75,6 @@ export function EmailSettings() {
await client.moveEmail(email.id, yearMailbox.id);
movedCount++;
} else {
// month mode
const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = currentMailboxes.find(
m => m.name === month && m.parentId === yearId
@@ -215,7 +99,6 @@ export function EmailSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
{!isSettingHidden('markAsReadDelay') && (
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
<Select
@@ -231,7 +114,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Delete Action */}
{!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2">
@@ -253,7 +135,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Archive Mode */}
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
<div className="flex flex-col gap-2">
<Select
@@ -287,7 +168,6 @@ export function EmailSettings() {
</div>
</SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch
checked={permanentlyDeleteJunk}
@@ -295,23 +175,6 @@ export function EmailSettings() {
/>
</SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */}
{!isSettingHidden('showPreview') && (
<SettingItem
label={t('show_preview.label')}
@@ -322,7 +185,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Disable Thread Grouping */}
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
<ToggleSwitch
checked={disableThreading}
@@ -330,7 +192,6 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Plain Text Mode */}
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
@@ -338,71 +199,6 @@ export function EmailSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Hide inline images from attachment list */}
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
@@ -410,7 +206,6 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
<div>
@@ -443,7 +238,6 @@ export function EmailSettings() {
})}
</div>
{/* Hover Actions Display Mode */}
<div className="pt-2 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
<div className="flex gap-2">
@@ -465,7 +259,6 @@ export function EmailSettings() {
</div>
</div>
{/* Corner Selection (only when floating) */}
{hoverActionsMode === 'floating' && (
<div className="pt-1 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
@@ -513,7 +306,6 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Emails Per Page */}
{!isSettingHidden('emailsPerPage') && (
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
<Select
@@ -528,75 +320,6 @@ export function EmailSettings() {
/>
</SettingItem>
)}
{/* Always Light Mode for Emails */}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
<ToggleSwitch
checked={emailAlwaysLightMode}
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
/>
</SettingItem>
{/* External Content */}
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
)}
{/* Default Mail Program */}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
{/* Trusted Senders */}
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
onClick={() => setShowTrustedModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</SettingItem>
{/* Trusted Senders - address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */}
<TrustedSendersModal
isOpen={showTrustedModal}
onClose={() => setShowTrustedModal(false)}
/>
</SettingsSection>
);
}
+6 -2
View File
@@ -140,9 +140,11 @@ interface AvatarProps {
contactPhotoUri?: string;
size?: "sm" | "md" | "lg";
className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean;
}
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -222,7 +224,9 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed
const imgSrc = disableImages
? null
: !imgError && !domainFailed
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
+16 -3
View File
@@ -45,9 +45,22 @@ export function FlagKR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
<path d="M1.5 0.5 a0.45 0.45 0 1 1 0 0.9 a0.45 0.45 0 1 0 0 -0.9" fill="#CD2E3A" />
<path d="M1.5 1.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" fill="#0047A0" />
<circle cx="1.5" cy="0.8" r="0.225" fill="#0047A0" />
<circle cx="1.5" cy="1.2" r="0.225" fill="#CD2E3A" />
<g stroke="#000" strokeWidth="0.06" strokeLinecap="round">
<line x1="0.42" y1="0.35" x2="0.78" y2="0.35" />
<line x1="0.42" y1="0.46" x2="0.78" y2="0.46" />
<line x1="0.42" y1="0.57" x2="0.78" y2="0.57" />
<line x1="2.22" y1="0.35" x2="2.58" y2="0.35" />
<line x1="2.22" y1="0.57" x2="2.58" y2="0.57" />
<line x1="0.42" y1="1.43" x2="0.78" y2="1.43" />
<line x1="0.42" y1="1.65" x2="0.78" y2="1.65" />
<line x1="2.22" y1="1.43" x2="2.58" y2="1.43" />
<line x1="2.22" y1="1.54" x2="2.58" y2="1.54" />
<line x1="2.22" y1="1.65" x2="2.58" y2="1.65" />
</g>
</svg>
);
}
+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 { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils";
interface UseMailboxDropOptions {
mailbox: Mailbox;
@@ -30,7 +31,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
@@ -106,31 +107,26 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders)
const destinationId = mailbox.originalId || mailbox.id;
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Move in a single bulk JMAP request (store handles counter updates).
await moveEmailsToMailbox(client, emailIds, mailbox.id);
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection();
}
// Refresh the current mailbox view
await fetchEmails(client, selectedMailbox);
// Refresh the current mailbox view (honors active search/filters)
await refreshCurrentMailbox(client);
const mailboxPath = getMailboxPath(mailbox, mailboxes);
// Call success callback if provided, otherwise use fallback
if (onSuccess) {
onSuccess(emailIds.length, mailbox.name);
onSuccess(emailIds.length, mailboxPath);
} else {
// Fallback for backward compatibility
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
toast.success("Email moved", `Moved to ${mailboxPath}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
}
}
@@ -148,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+20 -16
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store";
// Tailwind v4 breakpoints
@@ -12,26 +12,30 @@ const BREAKPOINTS = {
"2xl": 1536,
} as const;
const getMediaQueryServerSnapshot = () => false;
/**
* SSR-safe media query hook
* Returns false during SSR to prevent hydration mismatch
* SSR-safe media query hook. On SSR and the first hydration pass we report
* `false`; on all subsequent client renders (including client-side navigation
* remounts) we read `matchMedia` synchronously, so components don't flash
* through a one-frame "mobile" layout on desktop.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
const subscribe = useCallback(
(callback: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
},
[query],
);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const getSnapshot = useCallback(
() => window.matchMedia(query).matches,
[query],
);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
}
/**
+87
View File
@@ -0,0 +1,87 @@
import { useState, useCallback, useRef, useEffect } from "react";
interface PromptDialogState {
isOpen: boolean;
title: string;
message?: string;
placeholder?: string;
defaultValue: string;
confirmText?: string;
cancelText?: string;
onSubmit: (value: string) => void;
}
const INITIAL_STATE: PromptDialogState = {
isOpen: false,
title: "",
defaultValue: "",
onSubmit: () => {},
};
interface PromptOptions {
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function usePromptDialog() {
const [state, setState] = useState<PromptDialogState>(INITIAL_STATE);
const resolveRef = useRef<((value: string | null) => void) | null>(null);
useEffect(() => {
return () => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
};
}, []);
const prompt = useCallback(
(options: PromptOptions): Promise<string | null> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({
isOpen: true,
title: options.title,
message: options.message,
placeholder: options.placeholder,
defaultValue: options.defaultValue ?? "",
confirmText: options.confirmText,
cancelText: options.cancelText,
onSubmit: (value) => {
resolveRef.current = null;
resolve(value);
},
});
});
},
[]
);
const close = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
setState(INITIAL_STATE);
}, []);
return {
dialogProps: {
isOpen: state.isOpen,
onClose: close,
onSubmit: state.onSubmit,
title: state.title,
message: state.message,
placeholder: state.placeholder,
defaultValue: state.defaultValue,
confirmText: state.confirmText,
cancelText: state.cancelText,
},
prompt,
};
}
+63 -22
View File
@@ -1,9 +1,10 @@
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
import { format } from "date-fns";
import { format, parseISO } from "date-fns";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
import type { Calendar } from "@/lib/jmap/types";
interface DragCreateState {
@@ -12,9 +13,13 @@ interface DragCreateState {
endMinutes: number;
}
export type ResizeEdge = "top" | "bottom";
interface ResizeState {
eventId: string;
topPx: number;
heightPx: number;
startMinutes: number;
durationMinutes: number;
}
@@ -40,6 +45,7 @@ interface UseTimeGridInteractionsOptions {
created: string;
error: string;
};
isMobile?: boolean;
}
export function useTimeGridInteractions({
@@ -47,6 +53,7 @@ export function useTimeGridInteractions({
calendars,
onCreateRange,
errorMessages,
isMobile,
}: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = ((clientY - containerTop) / hourHeight) * 60;
@@ -72,6 +79,7 @@ export function useTimeGridInteractions({
dayKey: string,
dayDate: Date,
) => {
if (isMobile) return;
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
@@ -82,7 +90,7 @@ export function useTimeGridInteractions({
dayKey, dayDate, startMinutes: minutes,
pointerId: e.pointerId, startY: e.clientY, captured: false,
};
}, [snapToMinutes]);
}, [snapToMinutes, isMobile]);
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
@@ -130,43 +138,65 @@ export function useTimeGridInteractions({
// --- Resize ---
const resizeRef = useRef<{
eventId: string;
edge: ResizeEdge;
startY: number;
originalStartMinutes: number;
originalDurationMinutes: number;
originalHeightPx: number;
pointerId: number;
} | null>(null);
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
const computeResize = useCallback((
ref: NonNullable<typeof resizeRef.current>,
clientY: number,
): { startMinutes: number; durationMinutes: number } => {
const deltaY = clientY - ref.startY;
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15;
const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes;
if (ref.edge === "bottom") {
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
}
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
let newStart = ref.originalStartMinutes + deltaMinutes;
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
}, [hourHeight]);
const handleResizePointerDown = useCallback((
eventId: string,
edge: ResizeEdge,
originalStartMinutes: number,
originalDurationMinutes: number,
e: PointerEvent,
) => {
e.stopPropagation();
e.preventDefault();
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
resizeRef.current = {
eventId,
edge,
startY: e.clientY,
originalStartMinutes,
originalDurationMinutes,
originalHeightPx,
pointerId: e.pointerId,
};
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [hourHeight]);
}, []);
const handleResizePointerMove = useCallback((e: PointerEvent) => {
if (!resizeRef.current) return;
const deltaY = e.clientY - resizeRef.current.startY;
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
}, [hourHeight]);
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
setResizeVisual({
eventId: resizeRef.current.eventId,
topPx: (startMinutes / 60) * hourHeight,
heightPx: (durationMinutes / 60) * hourHeight,
startMinutes,
durationMinutes,
});
}, [hourHeight, computeResize]);
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
const resize = resizeRef.current;
@@ -179,11 +209,11 @@ export function useTimeGridInteractions({
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
const deltaY = e.clientY - resize.startY;
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
if (newDurationMinutes === resize.originalDurationMinutes) {
const startChanged = newStartMinutes !== resize.originalStartMinutes;
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
if (!startChanged && !durationChanged) {
setResizeVisual(null);
return;
}
@@ -205,14 +235,21 @@ export function useTimeGridInteractions({
try {
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined);
const updates: { start?: string; duration: string } = { duration: dur };
if (startChanged && event?.start) {
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
const deltaMinutes = newStartMinutes - resize.originalStartMinutes;
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
}
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
} catch (error) {
debug.error("Failed to resize event:", resize.eventId, error);
toast.error(errorMessages.resize);
} finally {
setResizeVisual(null);
}
}, [hourHeight, errorMessages.resize]);
}, [computeResize, errorMessages.resize]);
// --- Click / Double-click / Quick-create ---
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -317,14 +354,18 @@ export function useTimeGridInteractions({
const minutes = snapDragMinutes(e);
const newStart = new Date(day);
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
// Emit `start` as a floating wall-clock in the event's own timeZone.
// If we used browser-local, the server would reinterpret it in event.timeZone
// and shift the event by the offset between the two zones.
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) {
toast.error(errorMessages.move);
return;
}
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
+38 -3
View File
@@ -219,10 +219,26 @@ export function layoutOverlappingEvents(
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const columns: { event: CalendarEvent; end: number }[][] = [];
const result: TimedEventLayout[] = [];
let columns: { event: CalendarEvent; end: number }[][] = [];
let clusterStart = 0;
let clusterMaxEnd = 0;
const flushCluster = () => {
const total = columns.length;
for (let i = clusterStart; i < result.length; i++) {
result[i].totalColumns = total;
}
};
for (const event of sorted) {
if (columns.length > 0 && event.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= event.startMinutes)) {
@@ -236,10 +252,10 @@ export function layoutOverlappingEvents(
columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
}
clusterMaxEnd = Math.max(clusterMaxEnd, event.endMinutes);
}
const total = columns.length;
result.forEach(r => r.totalColumns = total);
flushCluster();
return result;
}
@@ -256,3 +272,22 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
return Object.keys(event.calendarIds || {})[0];
}
export function formatIsoInTimeZone(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
const hour = map.hour === "24" ? "00" : map.hour;
return `${map.year}-${map.month}-${map.day}T${hour}:${map.minute}:${map.second}`;
}
+56
View File
@@ -269,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId);
return;
}
for (const { id, receivedAt } of emails) {
const email = this.data.emails.find(e => e.id === id);
if (!email) continue;
const d = new Date(receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId);
if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId);
let destId = yearBox.id;
if (mode === 'month') {
let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id);
if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id);
destId = monthBox.id;
}
email.mailboxIds = { [destId]: true };
}
this.recalcMailboxCounts();
}
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [toMailboxId]: true };
@@ -283,6 +312,33 @@ export class DemoJMAPClient implements IJMAPClient {
return removed;
}
async markMailboxAsRead(mailboxId: string): Promise<number> {
let count = 0;
for (const email of this.data.emails) {
if (email.mailboxIds[mailboxId] && email.keywords.$seen !== true) {
email.keywords.$seen = true;
count++;
}
}
this.recalcMailboxCounts();
return count;
}
async markAllAsRead(excludeMailboxIds: string[] = []): Promise<number> {
const excluded = new Set(excludeMailboxIds);
let count = 0;
for (const email of this.data.emails) {
if (email.keywords.$seen === true) continue;
const mbIds = Object.keys(email.mailboxIds);
const onlyInExcluded = mbIds.length > 0 && mbIds.every(id => excluded.has(id));
if (onlyInExcluded) continue;
email.keywords.$seen = true;
count++;
}
this.recalcMailboxCounts();
return count;
}
async markAsSpam(emailId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
+20
View File
@@ -36,6 +36,26 @@ export function sanitizeEmailHtml(html: string): string {
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
}
/**
* Sanitize config for emails rendered inside a sandboxed iframe.
* Allows <style> tags because CSS is scoped to the iframe document and
* cannot leak into the host app. Scripts are still blocked by the sandbox
* attribute (no allow-scripts). Use ONLY for iframe-rendered content
* never for content rendered into the main DOM.
*/
export const EMAIL_IFRAME_SANITIZE_CONFIG = {
...EMAIL_SANITIZE_CONFIG,
FORBID_TAGS: EMAIL_SANITIZE_CONFIG.FORBID_TAGS.filter((t) => t !== 'style'),
};
/**
* Sanitize email HTML for rendering inside a sandboxed iframe.
* Preserves <style> tags so the email's own CSS is applied.
*/
export function sanitizeEmailHtmlForIframe(html: string): string {
return DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
}
/**
* Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources
+9
View File
@@ -85,8 +85,17 @@ export interface IJMAPClient {
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
): Promise<void>;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
+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> {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
@@ -1254,6 +1361,99 @@ export class JMAPClient implements IJMAPClient {
return totalDestroyed;
}
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
let totalMarked = 0;
let hasMore = true;
while (hasMore) {
const queryResponse = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: {
operator: "AND",
conditions: [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
],
},
limit: 500,
}, "0"],
]);
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
if (ids.length === 0) break;
const updates = Object.fromEntries(
ids.map((id) => [id, { "keywords/$seen": true }])
);
await this.request([
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
]);
totalMarked += ids.length;
hasMore = ids.length === 500;
}
return totalMarked;
}
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const excludeSet = new Set(excludeMailboxIds);
let totalMarked = 0;
let hasMore = true;
let position = 0;
while (hasMore) {
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: { notKeyword: "$seen" },
limit: 500,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["id", "mailboxIds"],
}, "1"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
const getResult = response.methodResponses?.[1]?.[1];
const ids: string[] = queryResult?.ids || [];
const emails: Array<{ id: string; mailboxIds?: Record<string, boolean> }> = getResult?.list || [];
if (ids.length === 0) break;
const targetIds = excludeSet.size === 0
? ids
: emails
.filter(e => {
const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : [];
return mbIds.some(id => !excludeSet.has(id));
})
.map(e => e.id);
if (targetIds.length > 0) {
const updates = Object.fromEntries(
targetIds.map((id) => [id, { "keywords/$seen": true }])
);
await this.request([
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
]);
totalMarked += targetIds.length;
}
hasMore = ids.length === 500;
position += ids.length;
}
return totalMarked;
}
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
@@ -1316,7 +1516,13 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.[createId]) {
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
const err = result.notCreated[createId];
const details = [err.type || 'unknown error'];
if (Array.isArray(err.properties) && err.properties.length > 0) {
details.push(`properties=[${err.properties.join(', ')}]`);
}
if (err.description) details.push(err.description);
throw new Error(`Failed to create mailbox: ${details.join(' ')}`);
}
const created = result?.created?.[createId];
+13 -7
View File
@@ -2,18 +2,23 @@ import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
function getClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
}
export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = process.env.OAUTH_ISSUER_URL;
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
const discoveryUrl = issuerUrl?.trim() || serverUrl;
if (issuerUrl !== undefined && !issuerUrl.trim()) {
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
@@ -36,8 +41,9 @@ export async function getMetadata(): Promise<OAuthMetadata | null> {
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
const params = new URLSearchParams({ ...base, client_id: clientId });
if (CLIENT_SECRET) {
params.set('client_secret', CLIENT_SECRET);
const secret = getClientSecret();
if (secret) {
params.set('client_secret', secret);
}
return params;
}
+19
View File
@@ -430,3 +430,22 @@ export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
traverse(nodes);
return result;
}
export function getMailboxPath(
mailbox: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
allMailboxes: Array<Pick<Mailbox, 'id' | 'name' | 'parentId'>>,
separator = ' ',
): string {
const byId = new Map(allMailboxes.map(m => [m.id, m]));
const names: string[] = [mailbox.name];
const visited = new Set<string>([mailbox.id]);
let parentId = mailbox.parentId;
while (parentId && !visited.has(parentId)) {
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) break;
names.unshift(parent.name);
parentId = parent.parentId;
}
return names.join(separator);
}
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Dateien",
"contacts": "Kontakte",
"sidebar_apps": "Sidebar-Apps",
"notifications": "Benachrichtigungen"
"notifications": "Benachrichtigungen",
"layout": "Layout",
"reading": "Lesen",
"composing": "Verfassen",
"content_senders": "Inhalte & Absender",
"about_data": "Über & Daten",
"debug": "Debug"
},
"tab_groups": {
"general": "Allgemein",
"account": "Konto & Identität",
"organization": "E-Mail-Organisation",
"apps": "Apps",
"system": "System"
"system": "System",
"appearance": "Darstellung",
"mail": "E-Mail",
"privacy": "Datenschutz & Sicherheit",
"advanced": "Erweitert"
},
"appearance": {
"title": "Darstellung",
@@ -1275,6 +1285,10 @@
"label": "Absender-Favicons (Experimentell)",
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
},
"show_avatars_in_junk": {
"label": "Avatare im Spam-Ordner anzeigen",
"description": "Profilbilder und Favicons für Absender im Spam-Ordner anzeigen. Standardmäßig deaktiviert, damit Phishing-Versuche nicht durch vertraute Logos legitim wirken."
},
"keyboard_shortcuts": {
"label": "Tastaturkürzel",
"description": "Verfügbare Tastaturkürzel anzeigen",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
},
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
"mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
"mark_all_folders_read": "Alle Ordner als gelesen markieren",
"new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...",
"rename": "Umbenennen...",
"empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen",
"refresh": "Aktualisieren",
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
"delete_confirm_title": "Ordner löschen",
"delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
"prompt_new_subfolder": "Gib einen Namen für den neuen Unterordner ein.",
"prompt_new_folder": "Gib einen Namen für den neuen Ordner ein.",
"prompt_rename": "Gib einen neuen Namen für diesen Ordner ein.",
"placeholder_folder_name": "Ordnername",
"create": "Erstellen",
"rename_confirm": "Umbenennen",
"toast_marked_read": "Ordner als gelesen markiert",
"toast_marked_read_count": "{count, plural, one {1 Nachricht} other {# Nachrichten}} als gelesen markiert",
"toast_already_read": "Keine ungelesenen Nachrichten",
"toast_marked_all_read": "Alle Ordner als gelesen markiert",
"toast_emptied": "Ordner geleert",
"toast_folder_created": "Ordner erstellt",
"toast_folder_renamed": "Ordner umbenannt",
"toast_folder_deleted": "Ordner gelöscht",
"toast_error_mark_read": "Konnte nicht als gelesen markiert werden",
"toast_error_empty": "Ordner konnte nicht geleert werden",
"toast_error_create": "Ordner konnte nicht erstellt werden",
"toast_error_rename": "Ordner konnte nicht umbenannt werden",
"toast_error_delete": "Ordner konnte nicht gelöscht werden",
"toast_error_delete_has_children": "Ordner enthält Unterordner. Entferne diese zuerst.",
"toast_error_delete_has_email": "Ordner ist nicht leer. Leere ihn zuerst."
},
"shortcuts": {
"title": "Tastaturkürzel",
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_search": "Keine Kontakte gefunden",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
"clear_search": "Suche löschen",
"import_vcard": "vCard importieren",
"delete_confirm_title": "Kontakt löschen",
@@ -1814,7 +1867,22 @@
"import_to_smime": "In S/MIME importieren",
"cert_already_imported": "Zertifikat bereits importiert",
"cert_imported": "Zertifikat importiert",
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
"cert_import_failed": "Import des Zertifikats fehlgeschlagen",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Weitere Aktionen",
"age_years": "{count, plural, one {1 Jahr alt} other {# Jahre alt}}",
"years_since": "{count, plural, one {1 Jahr} other {# Jahre}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Neuer Kontakt",
@@ -1919,7 +1989,13 @@
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden",
"delete": "Löschen"
"delete": "Löschen",
"upload_photo": "Foto hochladen",
"remove_photo": "Foto entfernen",
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
"photo_invalid": "Ungültige Bilddatei",
"change_photo": "Ändern"
},
"groups": {
"create": "Neue Gruppe",
@@ -1979,6 +2055,37 @@
"error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden"
},
"context_menu": {
"open": "Öffnen",
"edit": "Bearbeiten",
"send_email": "E-Mail senden",
"add_to_group": "Zur Gruppe hinzufügen",
"export_vcard": "Als vCard exportieren",
"delete": "Löschen",
"call": "Anrufen",
"duplicate": "Duplizieren",
"print": "Drucken"
},
"filters": {
"toggle": "Filter",
"select": "Auswählen",
"clear": "Zurücksetzen",
"close": "Schließen",
"title": "Erweiterte Filter",
"organization": "Firma",
"organization_placeholder": "z. B. Acme GmbH",
"job_title": "Berufsbezeichnung",
"job_title_placeholder": "z. B. Designer",
"location": "Ort",
"location_placeholder": "Stadt oder Land",
"email_domain": "E-Mail-Domain",
"email_domain_placeholder": "beispiel.de",
"birthday_month": "Geburtstag im",
"any_month": "Beliebiger Monat",
"has_email": "Mit E-Mail",
"has_phone": "Mit Telefon",
"has_photo": "Mit Foto"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Vorläufig",
"needs_action": "Antwort ausstehend",
"remove": "Entfernen",
"edit": "Bearbeiten",
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
"send_invitations": "Einladungen an Teilnehmer senden",
"status_summary": "{accepted} zugesagt, {pending} ausstehend",
+111 -7
View File
@@ -426,7 +426,9 @@
"cancel_info": "The organizer has cancelled this event.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled"
"event_status_cancelled": "Cancelled",
"expand": "Show details",
"collapse": "Hide details"
},
"send": "Send",
"more": "more"
@@ -671,14 +673,24 @@
"contacts": "Contacts",
"encryption": "Encryption",
"sidebar_apps": "Sidebar Apps",
"notifications": "Notifications"
"notifications": "Notifications",
"layout": "Layout",
"reading": "Reading",
"composing": "Composing",
"content_senders": "Content & Senders",
"about_data": "About & Data",
"debug": "Debug"
},
"tab_groups": {
"general": "General",
"account": "Account & Identity",
"organization": "Mail Organization",
"apps": "Apps",
"system": "System"
"system": "System",
"appearance": "Appearance",
"mail": "Mail",
"privacy": "Privacy & Security",
"advanced": "Advanced"
},
"appearance": {
"title": "Appearance",
@@ -1275,6 +1287,10 @@
"label": "Sender Favicons (Experimental)",
"description": "Show website icons as profile pictures for business senders"
},
"show_avatars_in_junk": {
"label": "Show Avatars in Junk Folder",
"description": "Show profile images and favicons for senders in the junk folder. Disabled by default to avoid lending visual legitimacy to phishing attempts."
},
"keyboard_shortcuts": {
"label": "Keyboard Shortcuts",
"description": "View available keyboard shortcuts",
@@ -1310,6 +1326,8 @@
"contacts": {
"title": "Contacts",
"description": "Import and export your contacts",
"group_by_letter_label": "Group by first letter",
"group_by_letter_description": "Show alphabetical section headers in the contact list",
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
@@ -1576,6 +1594,43 @@
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first."
},
"shortcuts": {
"title": "Keyboard Shortcuts",
"tip": "Press ? anytime to show this help",
@@ -1737,6 +1792,8 @@
"empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term",
"empty_filtered": "No contacts match your filters",
"empty_filtered_hint": "Try adjusting or clearing filters",
"clear_search": "Clear search",
"import_vcard": "Import vCard",
"delete_confirm_title": "Delete contact",
@@ -1814,7 +1871,22 @@
"calendar": "Calendar",
"calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL"
"freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "More actions",
"age_years": "{count, plural, one {1 year old} other {# years old}}",
"years_since": "{count, plural, one {1 year} other {# years}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1895,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "New Contact",
@@ -1919,7 +1993,13 @@
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact",
"delete": "Delete"
"delete": "Delete",
"upload_photo": "Upload photo",
"remove_photo": "Remove photo",
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
"photo_too_large": "Image is too large (max 10 MB)",
"photo_invalid": "Invalid image file",
"change_photo": "Change"
},
"groups": {
"create": "New Group",
@@ -1986,7 +2066,30 @@
"send_email": "Send email",
"add_to_group": "Add to group",
"export_vcard": "Export as vCard",
"delete": "Delete"
"delete": "Delete",
"call": "Call",
"duplicate": "Duplicate",
"print": "Print"
},
"filters": {
"toggle": "Filters",
"select": "Select",
"clear": "Clear",
"close": "Close",
"title": "Advanced filters",
"organization": "Company",
"organization_placeholder": "e.g. Acme Corp",
"job_title": "Job title",
"job_title_placeholder": "e.g. Designer",
"location": "Location",
"location_placeholder": "City or country",
"email_domain": "Email domain",
"email_domain_placeholder": "example.com",
"birthday_month": "Birthday in",
"any_month": "Any month",
"has_email": "Has email",
"has_phone": "Has phone",
"has_photo": "Has photo"
}
},
"calendar": {
@@ -2062,6 +2165,7 @@
"tentative": "Tentative",
"needs_action": "Needs action",
"remove": "Remove",
"edit": "Edit",
"email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants",
"status_summary": "{accepted} accepted, {pending} pending",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Archivos",
"contacts": "Contactos",
"sidebar_apps": "Apps de barra lateral",
"notifications": "Notificaciones"
"notifications": "Notificaciones",
"layout": "Diseño",
"reading": "Lectura",
"composing": "Redacción",
"content_senders": "Contenido y remitentes",
"about_data": "Acerca de y datos",
"debug": "Depuración"
},
"tab_groups": {
"general": "General",
"account": "Cuenta e identidad",
"organization": "Organización del correo",
"apps": "Aplicaciones",
"system": "Sistema"
"system": "Sistema",
"appearance": "Apariencia",
"mail": "Correo",
"privacy": "Privacidad y seguridad",
"advanced": "Avanzado"
},
"appearance": {
"title": "Apariencia",
@@ -1275,6 +1285,10 @@
"label": "Favicons de remitente (Experimental)",
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
},
"show_avatars_in_junk": {
"label": "Mostrar avatares en la carpeta de spam",
"description": "Mostrar imágenes de perfil y favicons de remitentes en la carpeta de spam. Desactivado por defecto para no dar apariencia legítima a los intentos de phishing."
},
"keyboard_shortcuts": {
"label": "Atajos de Teclado",
"description": "Ver atajos de teclado disponibles",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": {
"title": "Atajos de Teclado",
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
"empty_search": "Ningún contacto coincide con tu búsqueda",
"empty_search_hint": "Prueba con otro término de búsqueda",
"empty_filtered": "Ningún contacto coincide con tus filtros",
"empty_filtered_hint": "Ajusta o limpia los filtros",
"clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Eliminar contacto",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importar a S/MIME",
"cert_already_imported": "Certificado ya importado",
"cert_imported": "Certificado importado",
"cert_import_failed": "Error al importar el certificado"
"cert_import_failed": "Error al importar el certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Más acciones",
"age_years": "{count, plural, one {1 año} other {# años}}",
"years_since": "{count, plural, one {hace 1 año} other {hace # años}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nuevo contacto",
@@ -1919,7 +1989,13 @@
"email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto",
"delete": "Eliminar"
"delete": "Eliminar",
"upload_photo": "Subir foto",
"remove_photo": "Quitar foto",
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
"photo_invalid": "Archivo de imagen no válido",
"change_photo": "Cambiar"
},
"groups": {
"create": "Nuevo grupo",
@@ -1979,6 +2055,37 @@
"error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar correo",
"add_to_group": "Añadir al grupo",
"export_vcard": "Exportar como vCard",
"delete": "Eliminar",
"call": "Llamar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Seleccionar",
"clear": "Borrar",
"close": "Cerrar",
"title": "Filtros avanzados",
"organization": "Empresa",
"organization_placeholder": "p. ej. Acme",
"job_title": "Puesto",
"job_title_placeholder": "p. ej. Diseñador",
"location": "Ubicación",
"location_placeholder": "Ciudad o país",
"email_domain": "Dominio de correo",
"email_domain_placeholder": "ejemplo.com",
"birthday_month": "Cumpleaños en",
"any_month": "Cualquier mes",
"has_email": "Con correo",
"has_phone": "Con teléfono",
"has_photo": "Con foto"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provisional",
"needs_action": "Pendiente de respuesta",
"remove": "Eliminar",
"edit": "Editar",
"email_placeholder": "Añadir dirección de correo o buscar contactos",
"send_invitations": "Enviar invitaciones a los participantes",
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Fichiers",
"contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale",
"notifications": "Notifications"
"notifications": "Notifications",
"layout": "Mise en page",
"reading": "Lecture",
"composing": "Rédaction",
"content_senders": "Contenu et expéditeurs",
"about_data": "À propos et données",
"debug": "Débogage"
},
"tab_groups": {
"general": "Général",
"account": "Compte & Identité",
"organization": "Organisation des e-mails",
"apps": "Applications",
"system": "Système"
"system": "Système",
"appearance": "Apparence",
"mail": "Courrier",
"privacy": "Confidentialité et sécurité",
"advanced": "Avancé"
},
"appearance": {
"title": "Apparence",
@@ -1275,6 +1285,10 @@
"label": "Favicons des expéditeurs (Expérimental)",
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
},
"show_avatars_in_junk": {
"label": "Afficher les avatars dans le dossier indésirable",
"description": "Afficher les photos de profil et favicons des expéditeurs dans le dossier indésirable. Désactivé par défaut pour éviter de donner une apparence légitime aux tentatives d'hameçonnage."
},
"keyboard_shortcuts": {
"label": "Raccourcis clavier",
"description": "Voir les raccourcis clavier disponibles",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": {
"title": "Raccourcis clavier",
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_search": "Aucun contact ne correspond à votre recherche",
"empty_search_hint": "Essayez un autre terme de recherche",
"empty_filtered": "Aucun contact ne correspond à vos filtres",
"empty_filtered_hint": "Ajustez ou effacez les filtres",
"clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard",
"delete_confirm_title": "Supprimer le contact",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importer dans S/MIME",
"cert_already_imported": "Certificat déjà importé",
"cert_imported": "Certificat importé",
"cert_import_failed": "Échec de l'import du certificat"
"cert_import_failed": "Échec de l'import du certificat",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Plus d'actions",
"age_years": "{count, plural, one {1 an} other {# ans}}",
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nouveau contact",
@@ -1919,7 +1989,13 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact",
"delete": "Supprimer"
"delete": "Supprimer",
"upload_photo": "Téléverser une photo",
"remove_photo": "Supprimer la photo",
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
"photo_too_large": "Limage est trop grande (max 10 Mo)",
"photo_invalid": "Fichier image invalide",
"change_photo": "Modifier"
},
"groups": {
"create": "Nouveau groupe",
@@ -1979,6 +2055,37 @@
"error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact"
},
"context_menu": {
"open": "Ouvrir",
"edit": "Modifier",
"send_email": "Envoyer un courriel",
"add_to_group": "Ajouter au groupe",
"export_vcard": "Exporter en vCard",
"delete": "Supprimer",
"call": "Appeler",
"duplicate": "Dupliquer",
"print": "Imprimer"
},
"filters": {
"toggle": "Filtres",
"select": "Sélectionner",
"clear": "Effacer",
"close": "Fermer",
"title": "Filtres avancés",
"organization": "Entreprise",
"organization_placeholder": "ex. Acme",
"job_title": "Poste",
"job_title_placeholder": "ex. Designer",
"location": "Lieu",
"location_placeholder": "Ville ou pays",
"email_domain": "Domaine e-mail",
"email_domain_placeholder": "exemple.com",
"birthday_month": "Anniversaire en",
"any_month": "Tous les mois",
"has_email": "Avec e-mail",
"has_phone": "Avec téléphone",
"has_photo": "Avec photo"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provisoire",
"needs_action": "En attente de réponse",
"remove": "Retirer",
"edit": "Modifier",
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
"send_invitations": "Envoyer les invitations aux participants",
"status_summary": "{accepted} accepté(s), {pending} en attente",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "File",
"contacts": "Contatti",
"sidebar_apps": "App nella barra laterale",
"notifications": "Notifiche"
"notifications": "Notifiche",
"layout": "Layout",
"reading": "Lettura",
"composing": "Composizione",
"content_senders": "Contenuto e mittenti",
"about_data": "Informazioni e dati",
"debug": "Debug"
},
"tab_groups": {
"general": "Generale",
"account": "Account e identità",
"organization": "Organizzazione e-mail",
"apps": "Applicazioni",
"system": "Sistema"
"system": "Sistema",
"appearance": "Aspetto",
"mail": "Posta",
"privacy": "Privacy e sicurezza",
"advanced": "Avanzate"
},
"appearance": {
"title": "Aspetto",
@@ -1275,6 +1285,10 @@
"label": "Favicon dei mittenti (Sperimentale)",
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
},
"show_avatars_in_junk": {
"label": "Mostra avatar nella cartella spam",
"description": "Mostra immagini profilo e favicon dei mittenti nella cartella spam. Disattivato per impostazione predefinita per non dare apparenza legittima ai tentativi di phishing."
},
"keyboard_shortcuts": {
"label": "Scorciatoie da tastiera",
"description": "Visualizza le scorciatoie da tastiera disponibili",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_search": "Nessun contatto corrisponde alla ricerca",
"empty_search_hint": "Prova con un altro termine di ricerca",
"empty_filtered": "Nessun contatto corrisponde ai filtri",
"empty_filtered_hint": "Modifica o cancella i filtri",
"clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard",
"delete_confirm_title": "Elimina contatto",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importa in S/MIME",
"cert_already_imported": "Certificato già importato",
"cert_imported": "Certificato importato",
"cert_import_failed": "Importazione del certificato non riuscita"
"cert_import_failed": "Importazione del certificato non riuscita",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Altre azioni",
"age_years": "{count, plural, one {1 anno} other {# anni}}",
"years_since": "{count, plural, one {1 anno fa} other {# anni fa}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nuovo contatto",
@@ -1919,7 +1989,13 @@
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto",
"delete": "Elimina"
"delete": "Elimina",
"upload_photo": "Carica foto",
"remove_photo": "Rimuovi foto",
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
"photo_invalid": "File immagine non valido",
"change_photo": "Cambia"
},
"groups": {
"create": "Nuovo gruppo",
@@ -1979,6 +2055,37 @@
"error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto"
},
"context_menu": {
"open": "Apri",
"edit": "Modifica",
"send_email": "Invia email",
"add_to_group": "Aggiungi al gruppo",
"export_vcard": "Esporta come vCard",
"delete": "Elimina",
"call": "Chiama",
"duplicate": "Duplica",
"print": "Stampa"
},
"filters": {
"toggle": "Filtri",
"select": "Seleziona",
"clear": "Azzera",
"close": "Chiudi",
"title": "Filtri avanzati",
"organization": "Azienda",
"organization_placeholder": "es. Acme",
"job_title": "Ruolo",
"job_title_placeholder": "es. Designer",
"location": "Luogo",
"location_placeholder": "Città o paese",
"email_domain": "Dominio email",
"email_domain_placeholder": "esempio.com",
"birthday_month": "Compleanno in",
"any_month": "Qualsiasi mese",
"has_email": "Con email",
"has_phone": "Con telefono",
"has_photo": "Con foto"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provvisorio",
"needs_action": "In attesa di risposta",
"remove": "Rimuovi",
"edit": "Modifica",
"email_placeholder": "Aggiungi indirizzo email o cerca contatti",
"send_invitations": "Invia inviti ai partecipanti",
"status_summary": "{accepted} accettato/i, {pending} in attesa",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "ファイル",
"contacts": "連絡先",
"sidebar_apps": "サイドバーアプリ",
"notifications": "通知"
"notifications": "通知",
"layout": "レイアウト",
"reading": "閲覧",
"composing": "作成",
"content_senders": "コンテンツと送信者",
"about_data": "情報とデータ",
"debug": "デバッグ"
},
"tab_groups": {
"general": "一般",
"account": "アカウントと身元",
"organization": "メール整理",
"apps": "アプリ",
"system": "システム"
"system": "システム",
"appearance": "外観",
"mail": "メール",
"privacy": "プライバシーとセキュリティ",
"advanced": "詳細"
},
"appearance": {
"title": "外観",
@@ -1275,6 +1285,10 @@
"label": "送信者ファビコン(実験的)",
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
},
"show_avatars_in_junk": {
"label": "迷惑メールフォルダでアバターを表示",
"description": "迷惑メールフォルダの送信者にプロフィール画像とファビコンを表示します。フィッシング詐欺に正規のような見た目を与えないため、既定では無効です。"
},
"keyboard_shortcuts": {
"label": "キーボードショートカット",
"description": "利用可能なキーボードショートカットを表示",
@@ -1576,6 +1590,43 @@
"items_selected": "{count}件のメールを選択",
"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": {
"title": "キーボードショートカット",
"tip": "? キーを押すといつでもこのヘルプを表示できます",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_search": "検索に一致する連絡先がありません",
"empty_search_hint": "別の検索語をお試しください",
"empty_filtered": "フィルターに一致する連絡先がありません",
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
"clear_search": "検索をクリア",
"import_vcard": "vCardをインポート",
"delete_confirm_title": "連絡先を削除",
@@ -1814,7 +1867,22 @@
"import_to_smime": "S/MIME にインポート",
"cert_already_imported": "証明書はすでにインポートされています",
"cert_imported": "証明書をインポートしました",
"cert_import_failed": "証明書のインポートに失敗しました"
"cert_import_failed": "証明書のインポートに失敗しました",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "その他の操作",
"age_years": "{count}歳",
"years_since": "{count}年前"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "新しい連絡先",
@@ -1919,7 +1989,13 @@
"email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました",
"delete": "削除"
"delete": "削除",
"upload_photo": "写真をアップロード",
"remove_photo": "写真を削除",
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
"photo_too_large": "画像が大きすぎます (最大10MB)",
"photo_invalid": "無効な画像ファイル",
"change_photo": "変更"
},
"groups": {
"create": "新しいグループ",
@@ -1979,6 +2055,37 @@
"error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました"
},
"context_menu": {
"open": "開く",
"edit": "編集",
"send_email": "メール送信",
"add_to_group": "グループに追加",
"export_vcard": "vCardとしてエクスポート",
"delete": "削除",
"call": "電話する",
"duplicate": "複製",
"print": "印刷"
},
"filters": {
"toggle": "フィルター",
"select": "選択",
"clear": "クリア",
"close": "閉じる",
"title": "詳細フィルター",
"organization": "会社",
"organization_placeholder": "例: Acme",
"job_title": "役職",
"job_title_placeholder": "例: デザイナー",
"location": "場所",
"location_placeholder": "都市または国",
"email_domain": "メールドメイン",
"email_domain_placeholder": "example.com",
"birthday_month": "誕生月",
"any_month": "すべての月",
"has_email": "メールあり",
"has_phone": "電話あり",
"has_photo": "写真あり"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "仮承諾",
"needs_action": "未回答",
"remove": "削除",
"edit": "編集",
"email_placeholder": "メールアドレスを追加または連絡先を検索",
"send_invitations": "参加者に招待を送信",
"status_summary": "{accepted}人承諾、{pending}人保留",
+113 -5
View File
@@ -671,14 +671,24 @@
"contacts": "연락처",
"encryption": "암호화",
"sidebar_apps": "사이드바 앱",
"notifications": "알림"
"notifications": "알림",
"layout": "레이아웃",
"reading": "읽기",
"composing": "작성",
"content_senders": "콘텐츠 및 발신자",
"about_data": "정보 및 데이터",
"debug": "디버그"
},
"tab_groups": {
"general": "일반",
"account": "계정 및 인증",
"organization": "메일 정리",
"apps": "앱",
"system": "시스템"
"system": "시스템",
"appearance": "모양",
"mail": "메일",
"privacy": "개인정보 및 보안",
"advanced": "고급"
},
"appearance": {
"title": "화면 설정",
@@ -1275,6 +1285,10 @@
"label": "보낸 사람 파비콘 (실험적 기능)",
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
},
"show_avatars_in_junk": {
"label": "스팸함에서 아바타 표시",
"description": "스팸함의 보낸 사람에 대한 프로필 이미지와 파비콘을 표시해요. 피싱 메일이 정상적인 메일처럼 보이지 않도록 기본적으로 꺼져 있어요."
},
"keyboard_shortcuts": {
"label": "단축키",
"description": "사용 가능한 키보드 단축키를 확인해 보세요",
@@ -1576,6 +1590,43 @@
"items_selected": "{count}개의 메일 선택됨",
"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": {
"title": "단축키",
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
"empty_search": "검색된 연락처가 없어요",
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
"empty_filtered": "필터에 일치하는 연락처가 없어요",
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
"clear_search": "검색 지우기",
"import_vcard": "vCard 가져오기",
"delete_confirm_title": "연락처 삭제",
@@ -1814,7 +1867,22 @@
"calendar": "캘린더",
"calendar_uri": "캘린더 URL",
"scheduling_uri": "스케줄링 URL",
"freebusy_uri": "Free/Busy URL"
"freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "작업 더보기",
"age_years": "{count}세",
"years_since": "{count}년 전"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "새 연락처",
@@ -1919,7 +1989,13 @@
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
"email_error_inline": "이메일 형식이 잘못되었어요",
"save_failed": "연락처를 저장하지 못했어요",
"delete": "삭제"
"delete": "삭제",
"upload_photo": "사진 업로드",
"remove_photo": "사진 제거",
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
"photo_invalid": "잘못된 이미지 파일",
"change_photo": "변경"
},
"groups": {
"create": "새 그룹",
@@ -1979,6 +2055,37 @@
"error_create": "연락처를 만들지 못했어요",
"error_update": "연락처를 업데이트하지 못했어요",
"error_delete": "연락처를 삭제하지 못했어요"
},
"context_menu": {
"open": "열기",
"edit": "편집",
"send_email": "이메일 보내기",
"add_to_group": "그룹에 추가",
"export_vcard": "vCard로 내보내기",
"delete": "삭제",
"call": "통화",
"duplicate": "복제",
"print": "인쇄"
},
"filters": {
"toggle": "필터",
"select": "선택",
"clear": "지우기",
"close": "닫기",
"title": "고급 필터",
"organization": "회사",
"organization_placeholder": "예: Acme",
"job_title": "직책",
"job_title_placeholder": "예: 디자이너",
"location": "위치",
"location_placeholder": "도시 또는 국가",
"email_domain": "이메일 도메인",
"email_domain_placeholder": "example.com",
"birthday_month": "생일 월",
"any_month": "모든 달",
"has_email": "이메일 있음",
"has_phone": "전화번호 있음",
"has_photo": "사진 있음"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "미정",
"needs_action": "응답 필요",
"remove": "삭제",
"edit": "편집",
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
"send_invitations": "참석자에게 초대장 보내기",
"status_summary": "{accepted}명 수락, {pending}명 대기 중",
+114 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Kontakti",
"encryption": "Šifrēšana",
"sidebar_apps": "Sānu joslas lietotnes",
"notifications": "Paziņojumi"
"notifications": "Paziņojumi",
"layout": "Izkārtojums",
"reading": "Lasīšana",
"composing": "Rakstīšana",
"content_senders": "Saturs un sūtītāji",
"about_data": "Par un dati",
"debug": "Atkļūdošana"
},
"tab_groups": {
"general": "Vispārīgi",
"account": "Konts un identitāte",
"organization": "Pasta organizēšana",
"apps": "Lietotnes",
"system": "Sistēma"
"system": "Sistēma",
"appearance": "Izskats",
"mail": "Pasts",
"privacy": "Privātums un drošība",
"advanced": "Papildu"
},
"appearance": {
"title": "Izskats",
@@ -1256,6 +1266,10 @@
"label": "Sūtītāju ikonas (Eksperimentāli)",
"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": {
"label": "Īsinājumtaustiņi",
"description": "Skatīt pieejamos tastatūras īsinājumtaustiņus",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} vēstules atlasītas",
"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": {
"title": "Īsinājumtaustiņi",
"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_search": "Nav kontaktu, kas atbilstu vaicājumam",
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
"clear_search": "Notīrīt meklēšanu",
"import_vcard": "vCard imports",
"delete_confirm_title": "Dzēst kontaktu",
@@ -1810,7 +1863,22 @@
"calendar": "Kalendārs",
"calendar_uri": "Kalendāra URL",
"scheduling_uri": "Plānošanas URL",
"freebusy_uri": "Pieejamības URL"
"freebusy_uri": "Pieejamības URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Citas darbības",
"age_years": "{count, plural, one {1 gads} other {# gadi}}",
"years_since": "{count, plural, one {pirms 1 gada} other {pirms # gadiem}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1819,7 +1887,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Jauns kontakts",
@@ -1915,7 +1985,13 @@
"email_invalid": "Ievadiet derīgu e-pasta adresi",
"email_error_inline": "Nederīgs e-pasta formāts",
"save_failed": "Neizdevās saglabāt kontaktu",
"delete": "Dzēst"
"delete": "Dzēst",
"upload_photo": "Augšupielādēt foto",
"remove_photo": "Noņemt foto",
"photo_hint": "JPG vai PNG, līdz 10 MB. Tiks mainīts izmērs.",
"photo_too_large": "Attēls ir pārāk liels (maks. 10 MB)",
"photo_invalid": "Nederīgs attēla fails",
"change_photo": "Mainīt"
},
"groups": {
"create": "Jauna grupa",
@@ -1979,7 +2055,38 @@
"rename_category": "Pārdēvēt kategoriju",
"category_name_label": "Kategorijas nosaukums",
"category_renamed": "Kategorija pārdēvēta",
"category_rename_failed": "Neizdevās pārdēvēt kategoriju"
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
"context_menu": {
"open": "Atvērt",
"edit": "Rediģēt",
"send_email": "Sūtīt e-pastu",
"add_to_group": "Pievienot grupai",
"export_vcard": "Eksportēt kā vCard",
"delete": "Dzēst",
"call": "Zvanīt",
"duplicate": "Dublēt",
"print": "Drukāt"
},
"filters": {
"toggle": "Filtri",
"select": "Atlasīt",
"clear": "Notīrīt",
"close": "Aizvērt",
"title": "Paplašinātie filtri",
"organization": "Uzņēmums",
"organization_placeholder": "piem. Acme",
"job_title": "Amats",
"job_title_placeholder": "piem. Dizainers",
"location": "Atrašanās vieta",
"location_placeholder": "Pilsēta vai valsts",
"email_domain": "E-pasta domēns",
"email_domain_placeholder": "piemers.lv",
"birthday_month": "Dzimšanas diena",
"any_month": "Jebkurš mēnesis",
"has_email": "Ar e-pastu",
"has_phone": "Ar tālruni",
"has_photo": "Ar foto"
}
},
"calendar": {
"title": "Kalendārs",
@@ -2053,6 +2160,7 @@
"tentative": "Pagaidām",
"needs_action": "Nepieciešama atbilde",
"remove": "Noņemt",
"edit": "Rediģēt",
"email_placeholder": "Pievienojiet adresi vai kontaktu",
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
"status_summary": "{accepted} pieņēmuši, {pending} gaida",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Bestanden",
"contacts": "Contacten",
"sidebar_apps": "Zijbalk-apps",
"notifications": "Meldingen"
"notifications": "Meldingen",
"layout": "Indeling",
"reading": "Lezen",
"composing": "Opstellen",
"content_senders": "Inhoud en afzenders",
"about_data": "Over en gegevens",
"debug": "Debuggen"
},
"tab_groups": {
"general": "Algemeen",
"account": "Account & identiteit",
"organization": "E-mailorganisatie",
"apps": "Apps",
"system": "Systeem"
"system": "Systeem",
"appearance": "Weergave",
"mail": "E-mail",
"privacy": "Privacy en beveiliging",
"advanced": "Geavanceerd"
},
"appearance": {
"title": "Uiterlijk",
@@ -1275,6 +1285,10 @@
"label": "Afzender-favicons (Experimenteel)",
"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": {
"label": "Sneltoetsen",
"description": "Bekijk beschikbare sneltoetsen",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} e-mails geselecteerd",
"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": {
"title": "Sneltoetsen",
"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_search": "Geen contacten gevonden",
"empty_search_hint": "Probeer een andere zoekterm",
"empty_filtered": "Geen contacten voldoen aan uw filters",
"empty_filtered_hint": "Pas filters aan of wis ze",
"clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren",
"delete_confirm_title": "Contact verwijderen",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importeren naar S/MIME",
"cert_already_imported": "Certificaat is al geïmporteerd",
"cert_imported": "Certificaat geïmporteerd",
"cert_import_failed": "Importeren van certificaat mislukt"
"cert_import_failed": "Importeren van certificaat mislukt",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Meer acties",
"age_years": "{count, plural, one {1 jaar} other {# jaar}}",
"years_since": "{count, plural, one {1 jaar geleden} other {# jaar geleden}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nieuw contact",
@@ -1919,7 +1989,13 @@
"email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan",
"delete": "Verwijderen"
"delete": "Verwijderen",
"upload_photo": "Foto uploaden",
"remove_photo": "Foto verwijderen",
"photo_hint": "JPG of PNG, tot 10 MB. Wordt verkleind.",
"photo_too_large": "Afbeelding is te groot (max 10 MB)",
"photo_invalid": "Ongeldig afbeeldingsbestand",
"change_photo": "Wijzigen"
},
"groups": {
"create": "Nieuwe groep",
@@ -1979,6 +2055,37 @@
"error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen"
},
"context_menu": {
"open": "Openen",
"edit": "Bewerken",
"send_email": "E-mail verzenden",
"add_to_group": "Aan groep toevoegen",
"export_vcard": "Exporteren als vCard",
"delete": "Verwijderen",
"call": "Bellen",
"duplicate": "Dupliceren",
"print": "Afdrukken"
},
"filters": {
"toggle": "Filters",
"select": "Selecteren",
"clear": "Wissen",
"close": "Sluiten",
"title": "Geavanceerde filters",
"organization": "Bedrijf",
"organization_placeholder": "bijv. Acme",
"job_title": "Functie",
"job_title_placeholder": "bijv. Ontwerper",
"location": "Locatie",
"location_placeholder": "Stad of land",
"email_domain": "E-maildomein",
"email_domain_placeholder": "voorbeeld.nl",
"birthday_month": "Verjaardag in",
"any_month": "Elke maand",
"has_email": "Met e-mail",
"has_phone": "Met telefoon",
"has_photo": "Met foto"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Voorlopig",
"needs_action": "Reactie vereist",
"remove": "Verwijderen",
"edit": "Bewerken",
"email_placeholder": "E-mailadres toevoegen of contacten zoeken",
"send_invitations": "Uitnodigingen sturen naar deelnemers",
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
+113 -5
View File
@@ -671,14 +671,24 @@
"contacts": "Kontakty",
"encryption": "Szyfrowanie",
"sidebar_apps": "Aplikacje paska bocznego",
"notifications": "Powiadomienia"
"notifications": "Powiadomienia",
"layout": "Układ",
"reading": "Czytanie",
"composing": "Tworzenie",
"content_senders": "Treść i nadawcy",
"about_data": "O programie i dane",
"debug": "Debugowanie"
},
"tab_groups": {
"general": "Ogólne",
"account": "Konto i tożsamość",
"organization": "Organizacja poczty",
"apps": "Aplikacje",
"system": "System"
"system": "System",
"appearance": "Wygląd",
"mail": "Poczta",
"privacy": "Prywatność i bezpieczeństwo",
"advanced": "Zaawansowane"
},
"appearance": {
"title": "Wygląd",
@@ -1275,6 +1285,10 @@
"label": "Favikony nadawców (eksperymentalne)",
"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": {
"label": "Skróty klawiszowe",
"description": "Wyświetl dostępne skróty klawiszowe",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} zaznaczonych wiadomości",
"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": {
"title": "Skróty klawiszowe",
"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_search": "Żadne kontakty nie pasują do wyszukiwania",
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
"clear_search": "Wyczyść wyszukiwanie",
"import_vcard": "Importuj vCard",
"delete_confirm_title": "Usuń kontakt",
@@ -1814,7 +1867,22 @@
"calendar": "Kalendarz",
"calendar_uri": "Adres URL kalendarza",
"scheduling_uri": "Adres URL planowania",
"freebusy_uri": "Adres URL wolny/zajęty"
"freebusy_uri": "Adres URL wolny/zajęty",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Więcej działań",
"age_years": "{count, plural, one {1 rok} few {# lata} many {# lat} other {# lat}}",
"years_since": "{count, plural, one {1 rok temu} few {# lata temu} many {# lat temu} other {# lat temu}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nowy kontakt",
@@ -1919,7 +1989,13 @@
"email_invalid": "Wprowadź prawidłowy adres e-mail",
"email_error_inline": "Nieprawidłowy format adresu e-mail",
"save_failed": "Nie udało się zapisać kontaktu",
"delete": "Usuń"
"delete": "Usuń",
"upload_photo": "Prześlij zdjęcie",
"remove_photo": "Usuń zdjęcie",
"photo_hint": "JPG lub PNG, do 10 MB. Zostanie zmniejszone.",
"photo_too_large": "Obraz jest za duży (maks. 10 MB)",
"photo_invalid": "Nieprawidłowy plik obrazu",
"change_photo": "Zmień"
},
"groups": {
"create": "Nowa grupa",
@@ -1979,6 +2055,37 @@
"error_create": "Nie udało się utworzyć kontaktu",
"error_update": "Nie udało się zaktualizować kontaktu",
"error_delete": "Nie udało się usunąć kontaktu"
},
"context_menu": {
"open": "Otwórz",
"edit": "Edytuj",
"send_email": "Wyślij e-mail",
"add_to_group": "Dodaj do grupy",
"export_vcard": "Eksportuj jako vCard",
"delete": "Usuń",
"call": "Zadzwoń",
"duplicate": "Duplikuj",
"print": "Drukuj"
},
"filters": {
"toggle": "Filtry",
"select": "Zaznacz",
"clear": "Wyczyść",
"close": "Zamknij",
"title": "Filtry zaawansowane",
"organization": "Firma",
"organization_placeholder": "np. Acme",
"job_title": "Stanowisko",
"job_title_placeholder": "np. Projektant",
"location": "Lokalizacja",
"location_placeholder": "Miasto lub kraj",
"email_domain": "Domena e-mail",
"email_domain_placeholder": "przyklad.pl",
"birthday_month": "Urodziny w",
"any_month": "Dowolny miesiąc",
"has_email": "Z e-mailem",
"has_phone": "Z telefonem",
"has_photo": "Ze zdjęciem"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Wstępnie",
"needs_action": "Wymaga działania",
"remove": "Usuń",
"edit": "Edytuj",
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
"send_invitations": "Wyślij zaproszenia do uczestników",
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
+113 -5
View File
@@ -671,14 +671,24 @@
"files": "Arquivos",
"contacts": "Contactos",
"sidebar_apps": "Apps da barra lateral",
"notifications": "Notificações"
"notifications": "Notificações",
"layout": "Layout",
"reading": "Leitura",
"composing": "Composição",
"content_senders": "Conteúdo e remetentes",
"about_data": "Sobre e dados",
"debug": "Depuração"
},
"tab_groups": {
"general": "Geral",
"account": "Conta e identidade",
"organization": "Organização de e-mail",
"apps": "Aplicativos",
"system": "Sistema"
"system": "Sistema",
"appearance": "Aparência",
"mail": "Correio",
"privacy": "Privacidade e segurança",
"advanced": "Avançado"
},
"appearance": {
"title": "Aparência",
@@ -1275,6 +1285,10 @@
"label": "Favicons de remetente (Experimental)",
"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": {
"label": "Atalhos de Teclado",
"description": "Visualizar atalhos de teclado disponíveis",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} e-mails selecionados",
"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": {
"title": "Atalhos de Teclado",
"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_search": "Nenhum contato encontrado",
"empty_search_hint": "Tente outro termo de pesquisa",
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
"empty_filtered_hint": "Ajuste ou limpe os filtros",
"clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Excluir contato",
@@ -1814,7 +1867,22 @@
"import_to_smime": "Importar para S/MIME",
"cert_already_imported": "Certificado já importado",
"cert_imported": "Certificado importado",
"cert_import_failed": "Falha ao importar o certificado"
"cert_import_failed": "Falha ao importar o certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Mais ações",
"age_years": "{count, plural, one {1 ano} other {# anos}}",
"years_since": "{count, plural, one {há 1 ano} other {há # anos}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Novo contato",
@@ -1919,7 +1989,13 @@
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato",
"delete": "Excluir"
"delete": "Excluir",
"upload_photo": "Carregar foto",
"remove_photo": "Remover foto",
"photo_hint": "JPG ou PNG, até 10 MB. Será redimensionada.",
"photo_too_large": "A imagem é muito grande (máx. 10 MB)",
"photo_invalid": "Arquivo de imagem inválido",
"change_photo": "Alterar"
},
"groups": {
"create": "Novo grupo",
@@ -1979,6 +2055,37 @@
"error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir contato"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar e-mail",
"add_to_group": "Adicionar ao grupo",
"export_vcard": "Exportar como vCard",
"delete": "Excluir",
"call": "Ligar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Selecionar",
"clear": "Limpar",
"close": "Fechar",
"title": "Filtros avançados",
"organization": "Empresa",
"organization_placeholder": "ex. Acme",
"job_title": "Cargo",
"job_title_placeholder": "ex. Designer",
"location": "Localização",
"location_placeholder": "Cidade ou país",
"email_domain": "Domínio de e-mail",
"email_domain_placeholder": "exemplo.com",
"birthday_month": "Aniversário em",
"any_month": "Qualquer mês",
"has_email": "Com e-mail",
"has_phone": "Com telefone",
"has_photo": "Com foto"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Provisório",
"needs_action": "Aguardando resposta",
"remove": "Remover",
"edit": "Editar",
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
"send_invitations": "Enviar convites aos participantes",
"status_summary": "{accepted} aceito(s), {pending} pendente(s)",
+114 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Контакты",
"encryption": "Шифрование",
"sidebar_apps": "Приложения боковой панели",
"notifications": "Уведомления"
"notifications": "Уведомления",
"layout": "Макет",
"reading": "Чтение",
"composing": "Написание",
"content_senders": "Содержимое и отправители",
"about_data": "О программе и данные",
"debug": "Отладка"
},
"tab_groups": {
"general": "Общие",
"account": "Аккаунт и удостоверение",
"organization": "Организация почты",
"apps": "Приложения",
"system": "Система"
"system": "Система",
"appearance": "Внешний вид",
"mail": "Почта",
"privacy": "Конфиденциальность и безопасность",
"advanced": "Дополнительно"
},
"appearance": {
"title": "Внешний вид",
@@ -1117,7 +1127,7 @@
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
"name_label": "Название ключа",
"name_placeholder": "напр. Скрипт резервного копирования, CI runner",
"copy_now_warning": "Скопируйте этот API-ключ сейчас он больше не будет показан.",
"copy_now_warning": "Скопируйте этот API-ключ сейчас он больше не будет показан.",
"added": "API-ключ создан",
"removed": "API-ключ удалён",
"add_error": "Не удалось создать API-ключ",
@@ -1275,6 +1285,10 @@
"label": "Фавиконы отправителей (Экспериментально)",
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
},
"show_avatars_in_junk": {
"label": "Показывать аватары в папке «Спам»",
"description": "Показывать аватары и фавиконы отправителей в папке «Спам». По умолчанию отключено, чтобы фишинговые письма не выглядели правдоподобно."
},
"keyboard_shortcuts": {
"label": "Сочетания клавиш",
"description": "Просмотреть доступные сочетания клавиш",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} писем выбрано",
"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": {
"title": "Сочетания клавиш",
"tip": "Нажмите ? в любое время для отображения справки",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
"empty_search": "Нет контактов, соответствующих вашему запросу",
"empty_search_hint": "Попробуйте другой поисковый запрос",
"empty_filtered": "Нет контактов, соответствующих фильтрам",
"empty_filtered_hint": "Измените или очистите фильтры",
"clear_search": "Очистить поиск",
"import_vcard": "Импорт vCard",
"delete_confirm_title": "Удалить контакт",
@@ -1814,7 +1867,22 @@
"calendar": "Календарь",
"calendar_uri": "URL календаря",
"scheduling_uri": "URL планирования",
"freebusy_uri": "URL доступности"
"freebusy_uri": "URL доступности",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Другие действия",
"age_years": "{count, plural, one {# год} few {# года} many {# лет} other {# лет}}",
"years_since": "{count, plural, one {# год назад} few {# года назад} many {# лет назад} other {# лет назад}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Новый контакт",
@@ -1919,7 +1989,13 @@
"email_invalid": "Введите корректный адрес электронной почты",
"email_error_inline": "Неверный формат email",
"save_failed": "Не удалось сохранить контакт",
"delete": "Удалить"
"delete": "Удалить",
"upload_photo": "Загрузить фото",
"remove_photo": "Удалить фото",
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
"photo_invalid": "Недопустимый файл изображения",
"change_photo": "Изменить"
},
"groups": {
"create": "Новая группа",
@@ -1979,6 +2055,37 @@
"error_create": "Не удалось создать контакт",
"error_update": "Не удалось обновить контакт",
"error_delete": "Не удалось удалить контакт"
},
"context_menu": {
"open": "Открыть",
"edit": "Изменить",
"send_email": "Отправить письмо",
"add_to_group": "Добавить в группу",
"export_vcard": "Экспортировать как vCard",
"delete": "Удалить",
"call": "Позвонить",
"duplicate": "Дублировать",
"print": "Печать"
},
"filters": {
"toggle": "Фильтры",
"select": "Выбрать",
"clear": "Очистить",
"close": "Закрыть",
"title": "Расширенные фильтры",
"organization": "Компания",
"organization_placeholder": "напр. Acme",
"job_title": "Должность",
"job_title_placeholder": "напр. Дизайнер",
"location": "Местоположение",
"location_placeholder": "Город или страна",
"email_domain": "Домен e-mail",
"email_domain_placeholder": "primer.ru",
"birthday_month": "День рождения в",
"any_month": "Любой месяц",
"has_email": "С эл. почтой",
"has_phone": "С телефоном",
"has_photo": "С фото"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Предварительно",
"needs_action": "Требует ответа",
"remove": "Удалить",
"edit": "Редактировать",
"email_placeholder": "Добавьте адрес или найдите контакт",
"send_invitations": "Отправить приглашения участникам",
"status_summary": "{accepted} принято, {pending} ожидает",
+114 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Контакти",
"encryption": "Шифрування",
"sidebar_apps": "Програми бічної панелі",
"notifications": "Сповіщення"
"notifications": "Сповіщення",
"layout": "Макет",
"reading": "Читання",
"composing": "Написання",
"content_senders": "Вміст і відправники",
"about_data": "Про програму та дані",
"debug": "Налагодження"
},
"tab_groups": {
"general": "Загальний",
"account": "Обліковий запис і ідентифікатор",
"organization": "Організація пошти",
"apps": "програми",
"system": "система"
"system": "система",
"appearance": "Вигляд",
"mail": "Пошта",
"privacy": "Конфіденційність і безпека",
"advanced": "Додатково"
},
"appearance": {
"title": "Зовнішній вигляд",
@@ -1117,7 +1127,7 @@
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
"name_label": "Назва ключа",
"name_placeholder": "напр. Скрипт резервної копії, CI runner",
"copy_now_warning": "Скопіюйте цей API-ключ зараз він більше не буде показаний.",
"copy_now_warning": "Скопіюйте цей API-ключ зараз він більше не буде показаний.",
"added": "API-ключ створено",
"removed": "API-ключ видалено",
"add_error": "Не вдалося створити API-ключ",
@@ -1275,6 +1285,10 @@
"label": "Favicons відправника (експериментальний)",
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
},
"show_avatars_in_junk": {
"label": "Показувати аватари в папці «Спам»",
"description": "Показувати зображення профілю та фавікони відправників у папці «Спам». Типово вимкнено, щоб фішингові листи не виглядали достовірно."
},
"keyboard_shortcuts": {
"label": "Комбінації клавіш",
"description": "Переглянути доступні комбінації клавіш",
@@ -1576,6 +1590,43 @@
"items_selected": "Вибрано електронних листів: {count}",
"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": {
"title": "Комбінації клавіш",
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
"empty_search": "Жоден контакт не відповідає вашому пошуку",
"empty_search_hint": "Спробуйте інший термін пошуку",
"empty_filtered": "Жоден контакт не відповідає фільтрам",
"empty_filtered_hint": "Змініть або очистіть фільтри",
"clear_search": "Очистити пошук",
"import_vcard": "Імпорт vCard",
"delete_confirm_title": "Видалити контакт",
@@ -1814,7 +1867,22 @@
"calendar": "Календар",
"calendar_uri": "URL-адреса календаря",
"scheduling_uri": "URL-адреса планування",
"freebusy_uri": "Вільний/зайнятий URL"
"freebusy_uri": "Вільний/зайнятий URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Більше дій",
"age_years": "{count, plural, one {# рік} few {# роки} many {# років} other {# років}}",
"years_since": "{count, plural, one {# рік тому} few {# роки тому} many {# років тому} other {# років тому}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Новий контакт",
@@ -1919,7 +1989,13 @@
"email_invalid": "Введіть дійсну електронну адресу",
"email_error_inline": "Недійсний формат електронної пошти",
"save_failed": "Не вдалося зберегти контакт",
"delete": "Видалити"
"delete": "Видалити",
"upload_photo": "Завантажити фото",
"remove_photo": "Видалити фото",
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
"photo_invalid": "Недійсний файл зображення",
"change_photo": "Змінити"
},
"groups": {
"create": "Нова група",
@@ -1979,6 +2055,37 @@
"error_create": "Не вдалося створити контакт",
"error_update": "Не вдалося оновити контакт",
"error_delete": "Не вдалося видалити контакт"
},
"context_menu": {
"open": "Відкрити",
"edit": "Редагувати",
"send_email": "Надіслати лист",
"add_to_group": "Додати до групи",
"export_vcard": "Експортувати як vCard",
"delete": "Видалити",
"call": "Зателефонувати",
"duplicate": "Дублювати",
"print": "Друк"
},
"filters": {
"toggle": "Фільтри",
"select": "Вибрати",
"clear": "Очистити",
"close": "Закрити",
"title": "Розширені фільтри",
"organization": "Компанія",
"organization_placeholder": "напр. Acme",
"job_title": "Посада",
"job_title_placeholder": "напр. Дизайнер",
"location": "Місцезнаходження",
"location_placeholder": "Місто або країна",
"email_domain": "Домен e-mail",
"email_domain_placeholder": "priklad.ua",
"birthday_month": "День народження в",
"any_month": "Будь-який місяць",
"has_email": "З ел. поштою",
"has_phone": "З телефоном",
"has_photo": "З фото"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "Орієнтовний",
"needs_action": "Потребує дії",
"remove": "видалити",
"edit": "Редагувати",
"email_placeholder": "Додайте електронну адресу або знайдіть контакти",
"send_invitations": "Надішліть запрошення учасникам",
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
+113 -5
View File
@@ -671,14 +671,24 @@
"contacts": "联系人",
"encryption": "加密",
"sidebar_apps": "侧边栏应用",
"notifications": "通知"
"notifications": "通知",
"layout": "布局",
"reading": "阅读",
"composing": "撰写",
"content_senders": "内容和发件人",
"about_data": "关于和数据",
"debug": "调试"
},
"tab_groups": {
"general": "通用",
"account": "账户与身份",
"organization": "邮件组织",
"apps": "应用",
"system": "系统"
"system": "系统",
"appearance": "外观",
"mail": "邮件",
"privacy": "隐私和安全",
"advanced": "高级"
},
"appearance": {
"title": "外观",
@@ -1275,6 +1285,10 @@
"label": "发件人图标(实验性)",
"description": "使用企业发件人的网站图标作为头像"
},
"show_avatars_in_junk": {
"label": "在垃圾邮件文件夹中显示头像",
"description": "在垃圾邮件文件夹中显示发件人的头像和网站图标。默认关闭,避免钓鱼邮件因熟悉的图标看起来更可信。"
},
"keyboard_shortcuts": {
"label": "键盘快捷键",
"description": "查看可用快捷键",
@@ -1576,6 +1590,43 @@
"items_selected": "已选择 {count} 封邮件",
"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": {
"title": "键盘快捷键",
"tip": "按?随时显示此帮助",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
"empty_search": "没有符合您搜索条件的联系人",
"empty_search_hint": "尝试不同的搜索词",
"empty_filtered": "没有符合您筛选条件的联系人",
"empty_filtered_hint": "请调整或清除筛选条件",
"clear_search": "清除搜索",
"import_vcard": "导入 vCard",
"delete_confirm_title": "删除联系人",
@@ -1814,7 +1867,22 @@
"calendar": "日历",
"calendar_uri": "日历 URL",
"scheduling_uri": "调度 URL",
"freebusy_uri": "空闲/忙碌 URL"
"freebusy_uri": "空闲/忙碌 URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "更多操作",
"age_years": "{count}岁",
"years_since": "{count}年前"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1891,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "新联系人",
@@ -1919,7 +1989,13 @@
"email_invalid": "请输入有效的邮箱地址",
"email_error_inline": "邮箱地址格式无效",
"save_failed": "保存联系人失败",
"delete": "删除"
"delete": "删除",
"upload_photo": "上传照片",
"remove_photo": "移除照片",
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
"photo_too_large": "图像过大 (最大 10MB)",
"photo_invalid": "无效的图像文件",
"change_photo": "更改"
},
"groups": {
"create": "新建群组",
@@ -1979,6 +2055,37 @@
"error_create": "创建联系人失败",
"error_update": "无法更新联系人",
"error_delete": "删除联系人失败"
},
"context_menu": {
"open": "打开",
"edit": "编辑",
"send_email": "发送邮件",
"add_to_group": "加入群组",
"export_vcard": "导出为 vCard",
"delete": "删除",
"call": "拨打电话",
"duplicate": "复制",
"print": "打印"
},
"filters": {
"toggle": "筛选",
"select": "选择",
"clear": "清除",
"close": "关闭",
"title": "高级筛选",
"organization": "公司",
"organization_placeholder": "例如 Acme",
"job_title": "职位",
"job_title_placeholder": "例如 设计师",
"location": "所在地",
"location_placeholder": "城市或国家",
"email_domain": "邮箱域名",
"email_domain_placeholder": "example.com",
"birthday_month": "生日月份",
"any_month": "任意月份",
"has_email": "有邮箱",
"has_phone": "有电话",
"has_photo": "有照片"
}
},
"calendar": {
@@ -2054,6 +2161,7 @@
"tentative": "暂定",
"needs_action": "待处理",
"remove": "移除",
"edit": "编辑",
"email_placeholder": "添加邮箱地址或搜索联系人",
"send_invitations": "向参与者发送邀请",
"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",
"version": "1.4.14",
"version": "1.5.1",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
@@ -32,62 +32,62 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-image": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/extension-text-align": "^3.20.4",
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"asn1js": "^3.0.7",
"@tanstack/react-virtual": "^3.13.24",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"asn1js": "^3.0.10",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"dompurify": "^3.4.1",
"jszip": "^3.10.1",
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
"lucide-react": "^1.8.0",
"next": "^16.2.4",
"next-intl": "^4.9.1",
"otpauth": "^9.5.0",
"pkijs": "^3.3.3",
"pkijs": "^3.4.0",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"webcrypto-liner": "^1.4.3",
"zustand": "^5.0.9"
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.16",
"eslint": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.5",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.1",
"fake-indexeddb": "^6.2.5",
"globals": "^17.0.0",
"globals": "^17.5.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
"vitest": "^4.1.5"
},
"overrides": {
"elliptic": {
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)
CFG_APP_NAME="Bulwark Webmail"
CFG_JMAP_SERVER_URL=""
CFG_ALLOW_CUSTOM_JMAP_ENDPOINT="false"
CFG_STALWART_FEATURES="true"
CFG_OAUTH_ENABLED="false"
CFG_OAUTH_ONLY="false"
@@ -64,16 +65,23 @@ CFG_SETTINGS_SYNC_ENABLED="false"
CFG_SETTINGS_DATA_DIR="./data/settings"
CFG_LOG_FORMAT="text"
CFG_LOG_LEVEL="info"
CFG_APP_SHORT_NAME=""
CFG_APP_DESCRIPTION=""
CFG_LOGIN_COMPANY_NAME=""
CFG_LOGIN_LOGO_LIGHT_URL=""
CFG_LOGIN_LOGO_DARK_URL=""
CFG_FAVICON_URL=""
CFG_PWA_ICON_URL=""
CFG_PWA_THEME_COLOR=""
CFG_PWA_BACKGROUND_COLOR=""
CFG_APP_LOGO_LIGHT_URL=""
CFG_APP_LOGO_DARK_URL=""
CFG_LOGIN_IMPRINT_URL=""
CFG_LOGIN_PRIVACY_POLICY_URL=""
CFG_LOGIN_WEBSITE_URL=""
CFG_EXTENSION_DIRECTORY_URL=""
CFG_DEPLOY_METHOD=""
CFG_HOSTNAME="0.0.0.0"
CFG_PORT="3000"
CURRENT_STEP=0
@@ -593,9 +601,19 @@ screen_server_config() {
prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES"
echo ""
echo -e " ${BOLD}Network${RESET}"
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
echo -e " ${DIM}Adds a \"JMAP Server\" field to the login form so users can${RESET}"
echo -e " ${DIM}connect to any JMAP-compatible server (not just the one above).${RESET}"
echo -e " ${DIM}The external server must allow this domain in its CORS headers.${RESET}"
prompt_yesno "Allow users to override the JMAP server at login?" "$CFG_ALLOW_CUSTOM_JMAP_ENDPOINT" "CFG_ALLOW_CUSTOM_JMAP_ENDPOINT"
echo ""
echo -e " ${BOLD}Network${RESET}"
echo -e " ${DIM}The address the server binds to. Use \"0.0.0.0\" for all IPv4${RESET}"
echo -e " ${DIM}interfaces or \"::\" for dual-stack IPv4+IPv6.${RESET}"
echo ""
prompt_value "Hostname" "$CFG_HOSTNAME" "CFG_HOSTNAME"
echo ""
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
prompt_value "Port" "$CFG_PORT" "CFG_PORT"
draw_footer
@@ -770,32 +788,65 @@ screen_login_customization() {
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
echo ""
echo -e " ${BOLD}App Identity${RESET}"
echo -e " ${DIM}Short name used where space is limited (e.g. mobile home screen).${RESET}"
echo -e " ${DIM}Defaults to the application name set in step 1.${RESET}"
prompt_value "Short app name" "$CFG_APP_SHORT_NAME" "CFG_APP_SHORT_NAME"
echo ""
echo -e " ${DIM}Description shown by the OS when installing as a PWA.${RESET}"
prompt_value "App description" "$CFG_APP_DESCRIPTION" "CFG_APP_DESCRIPTION"
echo ""
echo -e " ${BOLD}Icons${RESET}"
echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}"
echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
echo ""
echo -e " ${DIM}Source image for PWA icons (192×192 and 512×512 are auto-generated).${RESET}"
echo -e " ${DIM}Falls back to FAVICON_URL if blank.${RESET}"
prompt_value "PWA icon URL" "$CFG_PWA_ICON_URL" "CFG_PWA_ICON_URL"
echo ""
echo -e " ${BOLD}PWA Colors${RESET}"
echo -e " ${DIM}Hex color (e.g. #3b82f6) used for the browser UI chrome when installed.${RESET}"
prompt_value "PWA theme color" "$CFG_PWA_THEME_COLOR" "CFG_PWA_THEME_COLOR"
echo ""
echo -e " ${DIM}Hex color shown on the PWA splash screen while loading.${RESET}"
prompt_value "PWA background color" "$CFG_PWA_BACKGROUND_COLOR" "CFG_PWA_BACKGROUND_COLOR"
echo ""
echo -e " ${BOLD}Logos${RESET}"
echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}"
prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
echo ""
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
echo ""
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
echo ""
echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}"
echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}"
prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
echo ""
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
echo ""
echo -e " ${BOLD}Login Page Links${RESET}"
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
echo ""
prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
echo ""
prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL"
echo ""
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL"
echo ""
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
echo -e " ${BOLD}Extension Directory${RESET}"
echo -e " ${DIM}URL of the BulwarkMail extension directory for the admin marketplace.${RESET}"
echo -e " ${DIM}Set this to enable browsing and installing plugins/themes.${RESET}"
echo -e " ${DIM}Leave blank to disable the marketplace.${RESET}"
prompt_value "Extension directory URL" "$CFG_EXTENSION_DIRECTORY_URL" "CFG_EXTENSION_DIRECTORY_URL"
if [[ -z "$CFG_APP_SHORT_NAME" && -z "$CFG_APP_DESCRIPTION" && -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_PWA_ICON_URL" && -z "$CFG_PWA_THEME_COLOR" && -z "$CFG_PWA_BACKGROUND_COLOR" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" && -z "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
echo ""
note "No branding configured. The app will use defaults."
fi
@@ -866,7 +917,9 @@ screen_summary() {
echo -e " ${CYAN}${BOLD}SERVER${RESET}"
echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}"
echo -e " JMAP Server URL ....... ${BOLD}${CFG_JMAP_SERVER_URL}${RESET}"
echo -e " Allow Custom JMAP ..... ${BOLD}${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}${RESET}"
echo -e " Stalwart Features ..... ${BOLD}${CFG_STALWART_FEATURES}${RESET}"
echo -e " Hostname .............. ${BOLD}${CFG_HOSTNAME}${RESET}"
echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}"
echo ""
@@ -905,9 +958,19 @@ screen_summary() {
# Login page
echo -e " ${CYAN}${BOLD}BRANDING${RESET}"
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
if [[ -n "$CFG_APP_SHORT_NAME" || -n "$CFG_APP_DESCRIPTION" || -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_PWA_ICON_URL" || -n "$CFG_PWA_THEME_COLOR" || -n "$CFG_PWA_BACKGROUND_COLOR" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
[[ -n "$CFG_APP_SHORT_NAME" ]] && \
echo -e " Short Name ............ ${BOLD}${CFG_APP_SHORT_NAME}${RESET}"
[[ -n "$CFG_APP_DESCRIPTION" ]] && \
echo -e " Description ........... ${BOLD}${CFG_APP_DESCRIPTION}${RESET}"
[[ -n "$CFG_FAVICON_URL" ]] && \
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}"
[[ -n "$CFG_PWA_ICON_URL" ]] && \
echo -e " PWA Icon URL .......... ${BOLD}${CFG_PWA_ICON_URL}${RESET}"
[[ -n "$CFG_PWA_THEME_COLOR" ]] && \
echo -e " PWA Theme Color ....... ${BOLD}${CFG_PWA_THEME_COLOR}${RESET}"
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && \
echo -e " PWA Background Color .. ${BOLD}${CFG_PWA_BACKGROUND_COLOR}${RESET}"
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
@@ -929,6 +992,13 @@ screen_summary() {
fi
echo ""
# Extensions
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
echo -e " ${CYAN}${BOLD}EXTENSIONS${RESET}"
echo -e " Directory URL ......... ${BOLD}${CFG_EXTENSION_DIRECTORY_URL}${RESET}"
echo ""
fi
# Deployment
echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}"
case "$CFG_DEPLOY_METHOD" in
@@ -978,10 +1048,15 @@ write_env_file() {
# -- JMAP Server (required) ---------------------------------------------------
APP_NAME=${CFG_APP_NAME}
JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL}
ALLOW_CUSTOM_JMAP_ENDPOINT=${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}
# -- Stalwart Mail Server Integration -----------------------------------------
STALWART_FEATURES=${CFG_STALWART_FEATURES}
# -- Server Listen Address ----------------------------------------------------
HOSTNAME=${CFG_HOSTNAME}
PORT=${CFG_PORT}
# -- OAuth / OpenID Connect ---------------------------------------------------
OAUTH_ENABLED=${CFG_OAUTH_ENABLED}
ENVEOF
@@ -1025,7 +1100,12 @@ LOG_LEVEL=${CFG_LOG_LEVEL}
# -- Branding ------------------------------------------------------------------
ENVEOF
[[ -n "$CFG_APP_SHORT_NAME" ]] && echo "APP_SHORT_NAME=${CFG_APP_SHORT_NAME}" >> "$ENV_FILE"
[[ -n "$CFG_APP_DESCRIPTION" ]] && echo "APP_DESCRIPTION=${CFG_APP_DESCRIPTION}" >> "$ENV_FILE"
[[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE"
[[ -n "$CFG_PWA_ICON_URL" ]] && echo "PWA_ICON_URL=${CFG_PWA_ICON_URL}" >> "$ENV_FILE"
[[ -n "$CFG_PWA_THEME_COLOR" ]] && echo "PWA_THEME_COLOR=${CFG_PWA_THEME_COLOR}" >> "$ENV_FILE"
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && echo "PWA_BACKGROUND_COLOR=${CFG_PWA_BACKGROUND_COLOR}" >> "$ENV_FILE"
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE"
@@ -1035,6 +1115,29 @@ ENVEOF
[[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo "LOGIN_PRIVACY_POLICY_URL=${CFG_LOGIN_PRIVACY_POLICY_URL}" >> "$ENV_FILE"
[[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo "LOGIN_WEBSITE_URL=${CFG_LOGIN_WEBSITE_URL}" >> "$ENV_FILE"
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
cat >> "$ENV_FILE" << ENVEOF
# -- Extension Directory / Marketplace ----------------------------------------
EXTENSION_DIRECTORY_URL=${CFG_EXTENSION_DIRECTORY_URL}
ENVEOF
fi
cat >> "$ENV_FILE" << ENVEOF
# -- Advanced (uncomment and set if needed) -----------------------------------
# SESSION_SECRET_FILE=/run/secrets/session_secret
# OAUTH_CLIENT_SECRET_FILE=/run/secrets/oauth_client_secret
# OAUTH_SCOPES=openid profile email
# OAUTH_EXTRA_SCOPES=
# COOKIE_SAME_SITE=lax
# COOKIE_SECURE=true
# TRUSTED_PROXY_DEPTH=1
# ALLOWED_FRAME_ANCESTORS='none'
# ADMIN_DATA_DIR=./data/admin
# ADMIN_SESSION_TTL=3600
ENVEOF
echo -e " ${OK} Written ${BOLD}.env.local${RESET}"
}
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