Compare commits

...
59 Commits
Author SHA1 Message Date
Linus Rath ec5593f567 Merge branch 'dev' into main 2026-04-23 00:10:22 +02:00
Linus Rath c30c38a7af chore: update version to 1.5.0 2026-04-23 00:07:34 +02:00
Linus Rath 7494fc1776 chore: update version to 1.5.0 2026-04-22 23:49:44 +02:00
Linus Rath 16fe92d2e9 chore: update version to 1.5.0 2026-04-22 23:31:32 +02:00
Linus Rath 3544e6a9e7 chore: bump dependencies to latest compatible versions 2026-04-22 23:03:05 +02:00
Linus Rath dd1f3e11e6 fix: retry mailbox fetch on first login to handle lazy provisioning #217 2026-04-22 22:45:49 +02:00
Linus Rath 6503482b55 feat: support resizing events from the top edge 2026-04-22 21:14:28 +02:00
Linus Rath cab57f6cd7 feat: add timezone-aware formatting for event start times and update utcEnd on duration change 2026-04-22 21:05:41 +02:00
Linus Rath 27f4fbdce4 feat: optimize layout of overlapping events in calendar 2026-04-22 20:53:04 +02:00
Linus Rath 39a228b20e feat: streamline mobile email viewer header layout 2026-04-22 20:50:12 +02:00
Linus Rath 4b069808d6 feat: add isMobile prop to calendar views and time grid interactions 2026-04-22 20:37:00 +02:00
Linus Rath 468851ff25 feat: show full folder path in move/drop toast 2026-04-22 00:14:49 +02:00
Linus Rath b7374570c8 feat: implement bulk email moving functionality in email store 2026-04-22 00:10:40 +02:00
Linus Rath 3c9fa5dc25 feat: implement batch archiving of emails 2026-04-22 00:05:38 +02:00
Linus Rath 3ade1c6473 fix: update mailbox retrieval logic in archive handling to use fresh state 2026-04-22 00:00:43 +02:00
Linus Rath 1810a474a2 fix: enhance error message for mailbox creation failure 2026-04-22 00:00:01 +02:00
Linus Rath 40982bc37b fix: align hover styles and selection-toggle target with focused item 2026-04-21 23:50:55 +02:00
Linus Rath 76f6149841 feat: add setting to toggle alphabetical grouping in contacts list 2026-04-21 23:22:18 +02:00
Linus Rath 7d6a3c8c76 feat: group contacts by first letter with sticky section headers 2026-04-21 23:15:13 +02:00
Linus Rath 361ad49f5f fix: read matchMedia synchronously on client to prevent layout flicker 2026-04-21 23:02:21 +02:00
Linus Rath f9aa5cbaee fix: skip checkAuth on route change when already authenticated 2026-04-21 22:54:02 +02:00
Linus Rath 3f36045990 feat: reorganize settings into 6 groups with clearer tabs 2026-04-21 22:22:02 +02:00
Linus Rath 9a44babcf1 feat: add collapsible details to calendar invitation banner and update sanitization config for iframe rendering 2026-04-21 21:21:42 +02:00
Linus Rath f032758303 feat: revamp contact detail, filters, and add photo/print/duplicate 2026-04-21 21:16:54 +02:00
Linus Rath 92c7f74420 feat: add right-click context menu to contacts 2026-04-21 19:33:00 +02:00
Linus Rath c2e4518cfa feat: add account addition button to navigation rail 2026-04-21 19:20:50 +02:00
Linus Rath 00f33afdf9 feat: add contact activity component with recent emails and upcoming events 2026-04-21 19:18:36 +02:00
Linus Rath e566cfe687 feat: add API Keys management and IP allowlist for App Passwords 2026-04-21 18:59:47 +02:00
Linus Rath 6b7c849332 refactor: remove Stalwart API URL configuration 2026-04-21 17:50:18 +02:00
Linus Rath 30c4afb977 i18n: add missing translation keys 2026-04-21 17:42:52 +02:00
Linus Rath 1f60671886 fix: clean up unused imports and improve TOTP QR code rendering 2026-04-21 17:30:00 +02:00
Linus Rath 794001fdbd feat: migrate Stalwart management API to JMAP x: methods (0.16)
Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
2026-04-21 17:29:23 +02:00
Linus Rath 9ad2facad3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-21 14:12:03 +02:00
Linus Rath 89d8282846 fix: include original attachments when forwarding an email #214 2026-04-21 13:58:02 +02:00
Denis TeyssierandLinus Rath c3960a99be fix: removed allow-same-origin to avoid anti-pattern permissions on iframe sandbox #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath e73ffa7449 fix: added sandbox allow-scripts to PDF file preview modal #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath 37bd490072 fix: added media-src CSP rule for media attachments #209 2026-04-20 14:55:50 +02:00
Linus Rath 24c53e5ce7 fix: apply archive context-menu action to all selected emails # 212 2026-04-20 14:35:56 +02:00
Linus Rath aa7f886795 refactor: remove deprecated push notification API routes and related logic 2026-04-20 12:41:38 +02:00
Linus Rath 00dec8c5a0 fix: correct regex escape for hyphen in FCM token validation 2026-04-20 12:08:41 +02:00
Linus Rath 578e60c0bc feat: nevermind, migrate push notification handling from UnifiedPush to FCM 2026-04-20 12:06:30 +02:00
Linus Rath 8b21851353 feat: migrate to UnifiedPush 2026-04-20 10:54:26 +02:00
Linus Rath 15006086d2 feat: implement JMAP push notification handling and subscription management 2026-04-20 08:26:09 +02:00
Linus Rath bc3b923945 fix: apply mobile toolbar spacing fix to email viewer action bar 2026-04-19 17:59:58 +02:00
Linus Rath 76ba9e5f85 fix: retry session fetch with Authorization after redirect strips it #210 2026-04-19 16:40:55 +02:00
Linus Rath 44eb5fced2 fix: stop birthday calendar from re-showing after manual hide #204 2026-04-19 15:04:27 +02:00
Linus Rath 172d8267ef fix: persist typed calendar participant on save without requiring Enter #206 2026-04-19 13:47:17 +02:00
Linus Rath f162f1e3d4 feat: refresh JMAP on reload gestures, fix mobile bottom nav spacing 2026-04-19 13:43:44 +02:00
Linus Rath 6fa0029d0b feat: add right-click context menu on calendar events 2026-04-18 13:46:49 +02:00
nesgarboandLinus Rath 028e78a0c9 fix: serve PWA manifest dynamically to respect runtime APP_NAME env var
Delete static public/manifest.json (hardcoded "Bulwark Webmail") and mark
app/manifest.ts as force-dynamic so Next.js evaluates APP_NAME at request
time instead of build time, fixing the native browser install prompt.

Closes #207
2026-04-18 13:20:14 +02:00
Linus Rath 440a4e919a Revert "perf: make root HTML cacheable by dropping per-request CSP nonce"
This reverts commit 522bf6a019.
2026-04-18 13:10:42 +02:00
Linus Rath b8f39198e1 fix: allow blob: in frame-src CSP for attachment previews #209 2026-04-18 13:04:25 +02:00
Linus Rath 966bbe3957 fix: add public JavaScript files to ESLint ignore list 2026-04-18 13:01:52 +02:00
Linus Rath 522bf6a019 perf: make root HTML cacheable by dropping per-request CSP nonce 2026-04-18 13:01:12 +02:00
Linus Rath 1689315c3a feat: add setting to hide inline images from attachment list 2026-04-18 01:07:34 +02:00
Linus Rath d4f7ae522e fix: use cid references for inline images #163 2026-04-18 00:57:46 +02:00
Linus Rath f05f70a9e5 fix: prevent XSS via quote injection in plain-text email linkifier 2026-04-18 00:35:21 +02:00
Linus Rath 850ee73048 fix: preserve Nextcloud Mail filter markers across saves #201 2026-04-17 02:08:04 +02:00
Linus Rath 5842f3f914 fix: preserve birthday calendar visibility across navigation #204 2026-04-17 01:54:08 +02:00
160 changed files with 8786 additions and 95800 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}
-5
View File
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Set to "false" to disable if using a non-Stalwart JMAP server.
# STALWART_FEATURES=true
# If your reverse proxy doesn't forward Stalwart management API paths
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
# STALWART_API_URL=https://admin.example.com
# =============================================================================
# OAuth / OpenID Connect (optional)
# =============================================================================
+44
View File
@@ -1,5 +1,49 @@
# Changelog
## 1.5.0 (2026-04-22)
### Breaking Changes
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
### Features
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
- **Admin**: Add API Keys management and IP allowlist for App Passwords
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
- **Contacts**: Add contact activity component showing recent emails and upcoming events
- **Contacts**: Add right-click context menu
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
- **Calendar**: Support resizing events from the top edge
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
- **Calendar**: Optimize layout of overlapping events
- **Calendar**: Add collapsible details to calendar invitation banner
- **Email**: Implement batch archiving and bulk moving of emails
- **Email**: Show full folder path in move/drop toast
- **Settings**: Reorganize settings into 6 groups with clearer tabs
- **Navigation**: Add account-addition button to the navigation rail
- **Mobile**: Streamline email viewer header layout
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
### Fixes
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
- **Mailbox**: Improve error message on mailbox creation failure
- **Auth**: Skip `checkAuth` on route change when already authenticated
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
- **UI**: Align hover styles and selection-toggle target with focused item
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
### Refactor
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
### Chore
- **i18n**: Add missing translation keys
- **Deps**: Bump dependencies to latest compatible versions
## 1.4.14 (2026-04-16)
Thank you for your donations:
+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 -249
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.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** - Three-pane layout with sidebar, email list, and viewer
</td>
<td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** - Month, week, day, and agenda views with event management
</td>
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
**Contacts** - Contact management with groups and vCard support
</td>
<td width="50%">
<img src="screenshots/files.png" width="100%" alt="File browser">
**Files** - Cloud file browser with upload, preview, and folder navigation
</td>
<td><sub><b>Calendar</b> month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
<td><sub><b>Contacts</b> multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
</tr>
<tr>
<td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</tr>
<tr>
<td><sub><b>Themes</b> bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<td><sub><b>Plugins</b> extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
<tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr>
</table>
<details>
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
## Overview
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
**Light mode** - Full theme support with intelligent color transformation
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
</td>
<td width="50%">
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard.
<img src="screenshots/settings.png" width="100%" alt="Settings">
**Settings** - Appearance, identities, filters, templates, and more
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/login.png" width="100%" alt="Login page">
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
</td>
<td width="50%">
</td>
</tr>
</table>
</details>
## Features
### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
- **Unified mailbox** - view emails across all accounts in a single list
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** - archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer
- **Answered/forwarded status icons** in email list and thread views
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
- **Star/unstar** with configurable mark-as-read delay
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
- **Sender avatars** - favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction
- **Plain text composer mode** and auto-select reply identity
- **Reply-to addresses** support in the composer
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
- **message/rfc822 unwrapping** for embedded messages
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
- **Tag counts** - unread and total counts displayed in sidebar
- **Browser history sync** - back/forward navigation mirrors mail view state
### Calendar
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
- **Event hover preview** popover with configurable details
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview, bulk create, and UID deduplication
- **iCal / webcal subscriptions** with editing and batch import
- **Birthday calendar** - auto-generated from contacts
- **Virtual locations** - video conference URLs as first-class event fields
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
- **Shared calendars** with visual grouping in the sidebar
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
- **Week numbers** in mini-calendar sidebar
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push
### Contacts
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
- **Collapsible sidebar** with address book grouping and bulk operations
- **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection
- **Trusted senders** stored in a dedicated JMAP address book
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** - multi-select, delete, group add, export
### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
- **External rule preservation** - rules authored in other clients are displayed and preserved
- **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Files
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
- **Dynamic upload limits** - respects the server-configured maximum upload size
- **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** - cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access
- **Bulk operations** - multi-select, delete, move, download
### Security & Privacy
- **External content blocked** by default - trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
- **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
- **TOTP two-factor authentication**
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
- **Newsletter unsubscribe** (RFC 2369)
### Interface
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation
- **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** - full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Interactive guided tour** - onboarding walkthrough for new users
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
- **Settings sync** - preferences synchronized with the server (encrypted)
- **Storage quota** display
- **Version badge** in settings
- **Focused mode** with proper viewport bounds
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
### Identity Management
- **Multiple sender identities** with per-identity signatures
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list
### Multi-Account
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
- **Account switcher** with connection status, default account selection, and per-account logout
- **Per-account settings** - encrypted settings storage with server-side sync
- **Shared folders** across accounts
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
### Admin & Extensibility
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- **Bundled plugins** - Jitsi Meet calendar integration
### Operations
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
- **Automatic update check** - server logs when a newer release is available
- **Logging categories** with `text` or `json` formats for log aggregation
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
Full feature list: **[FEATURES.md](FEATURES.md)**.
---
## Quick Start
### Docker (recommended)
### Docker
```bash
docker run -d -p 3000:3000 \
@@ -255,7 +82,7 @@ Or with Docker Compose:
```bash
cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL
# Edit .env.local set JMAP_SERVER_URL
docker compose up -d
```
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL
# Edit .env.local set JMAP_SERVER_URL
npm run build && npm start
```
### Development
```bash
npm run dev # Start dev server (mock JMAP server included)
npm run typecheck # Type checking
npm run lint # Linting
npm run dev # Dev server with a mock JMAP server
npm run typecheck
npm run lint
```
## Configuration
Edit `.env.local`:
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env
# Required
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail
```
All variables are **runtime** - Docker deployments can be configured without rebuilding.
<details>
<summary>Server Listen Address</summary>
<summary>Server listen address</summary>
```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000 # Default listen port
PORT=3000
```
</details>
<details>
<summary>OAuth2/OIDC (SSO)</summary>
<summary>OAuth2 / OIDC</summary>
```env
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
@@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details>
<details>
<summary>Remember Me & Settings Sync</summary>
<summary>Session & settings sync</summary>
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details>
<details>
<summary>Custom JMAP Endpoint</summary>
<summary>Custom JMAP endpoint</summary>
```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
ALLOW_CUSTOM_JMAP_ENDPOINT=true
```
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
</details>
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
```env
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail # Home-screen label on mobile
APP_DESCRIPTION=Your personal mail # Shown during PWA install
APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
</details>
<details>
<summary>Extension Directory</summary>
<summary>Extension directory</summary>
```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
@@ -382,14 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
</details>
<details>
<summary>Stalwart Integration & Logging</summary>
<summary>Stalwart integration & logging</summary>
```env
STALWART_FEATURES=true # Password change, sieve filters, etc.
STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug"
LOG_LEVEL=info # error | warn | info | debug
```
</details>
@@ -424,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[GNU AGPL v3](LICENSE)
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
## Acknowledgments
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
+1 -1
View File
@@ -1 +1 @@
1.4.14
1.5.0
+132 -3
View File
@@ -30,6 +30,10 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
@@ -122,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);
});
@@ -161,9 +172,14 @@ export default function CalendarPage() {
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
// Auto-add birthday calendar to selected IDs when enabled
// Auto-add birthday calendar to selected IDs only when the setting flips
// off→on. Firing on every mount would undo a user's manual hide via the
// sidebar each time they navigate back to the calendar (see #204).
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
useEffect(() => {
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
const wasShown = prevShowBirthdayRef.current;
prevShowBirthdayRef.current = showBirthdayCalendar;
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
}
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -357,6 +373,18 @@ export default function CalendarPage() {
openEditModal(event);
}, [closeDetail, openEditModal]);
const {
contextMenu: eventContextMenu,
openContextMenu: openEventContextMenu,
closeContextMenu: closeEventContextMenu,
menuRef: eventContextMenuRef,
} = useContextMenu<CalendarEvent>();
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
closeDetail();
openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
@@ -417,6 +445,20 @@ export default function CalendarPage() {
}
}, [client, fetchEvents]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
await Promise.all([
fetchCalendars(client),
refetchCurrentRange(),
refreshAllSubscriptions(client),
]);
},
});
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) {
return;
@@ -732,6 +774,73 @@ export default function CalendarPage() {
}
}, [detailEvent, client, updateEvent, t]);
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
});
if (event.locations) data.locations = structuredClone(event.locations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, createEvent, openEditModal, t]);
const handleExportICS = useCallback((event: CalendarEvent) => {
try {
downloadEventICS(event);
toast.success(t("notifications.event_exported"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
try {
await navigator.clipboard.writeText(event.title || "");
toast.success(t("notifications.title_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
const uri = event.virtualLocations
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
: undefined;
if (!uri) return;
try {
await navigator.clipboard.writeText(uri);
toast.success(t("notifications.link_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
handleDeleteEvent(event.id, hasParticipants || undefined);
}, [handleDeleteEvent]);
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
if (!detailEvent || !client) return;
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
@@ -841,6 +950,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
@@ -857,6 +967,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
@@ -875,6 +986,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
@@ -892,6 +1004,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat}
/>
);
@@ -1106,6 +1219,22 @@ export default function CalendarPage() {
</div>
)}
{eventContextMenu.data && (
<EventContextMenu
event={eventContextMenu.data}
position={eventContextMenu.position}
isOpen={eventContextMenu.isOpen}
onClose={closeEventContextMenu}
menuRef={eventContextMenuRef}
onEdit={() => openEditModal(eventContextMenu.data!)}
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
onExportICS={() => handleExportICS(eventContextMenu.data!)}
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
/>
)}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
+81 -26
View File
@@ -26,6 +26,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
type View =
@@ -97,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);
});
@@ -123,6 +131,16 @@ export default function ContactsPage() {
}
}, [client, supportsSync, fetchContacts]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => {
if (!client) return;
await fetchContacts(client);
},
});
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
@@ -161,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();
@@ -248,9 +251,7 @@ export default function ContactsPage() {
setView("edit");
};
const handleDelete = async () => {
if (!selectedContact) return;
const deleteContactById = useCallback(async (contactId: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm"),
@@ -261,18 +262,60 @@ export default function ContactsPage() {
try {
if (supportsSync && client) {
await deleteContact(client, selectedContact.id);
await deleteContact(client, contactId);
} else {
deleteLocalContact(selectedContact.id);
deleteLocalContact(contactId);
}
toast.success(t("toast.deleted"));
setView("list");
if (selectedContactId === contactId) setView("list");
} catch (error) {
console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete"));
}
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
await deleteContactById(selectedContact.id);
};
const handleEditContact = useCallback((id: string) => {
setSelectedContact(id);
setView("edit");
}, [setSelectedContact]);
const handleDeleteContact = useCallback((contact: ContactCard) => {
void deleteContactById(contact.id);
}, [deleteContactById]);
const handleAddContactToGroup = useCallback((id: string) => {
clearSelection();
toggleContactSelection(id);
if (groups.length === 0) {
setView("group-create");
return;
}
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) {
await createContact(client, data);
@@ -574,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}
/>
);
@@ -669,7 +722,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
@@ -679,6 +731,9 @@ export default function ContactsPage() {
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
/>
</div>
@@ -690,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"); }}
/>
)}
</>
+18 -1
View File
@@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
@@ -104,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);
});
@@ -127,6 +135,15 @@ export default function FilesPage() {
}
}, [isAuthenticated, client, initClient]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsFiles === true,
onRefresh: async () => {
await refresh();
},
});
// Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client);
useEffect(() => {
+60 -23
View File
@@ -8,7 +8,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
@@ -21,6 +21,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
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 { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
@@ -408,6 +409,20 @@ export default function Home() {
handlers: keyboardHandlers,
});
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh mail data via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
const state = useEmailStore.getState();
await Promise.all([
state.fetchMailboxes(client),
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
]);
},
});
// Update page title based on context
useEffect(() => {
let title = appName;
@@ -440,8 +455,15 @@ export default function Home() {
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
@@ -478,7 +500,10 @@ export default function Home() {
// Load mailboxes and emails when authenticated (only if not already loaded)
useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
const loadData = async () => {
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([
@@ -490,6 +515,15 @@ export default function Home() {
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
// On first login the server may still be provisioning mailboxes.
// Retry a few times with back-off before giving up.
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
// Fetch emails for the selected mailbox
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
@@ -523,6 +557,12 @@ export default function Home() {
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
};
}
// Cleanup push notifications on unmount
@@ -644,7 +684,7 @@ export default function Home() {
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
}) => {
if (!client) return;
@@ -798,8 +838,11 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return;
// Find archive mailbox
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
const currentMailboxes = useEmailStore.getState().mailboxes;
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return;
const { archiveMode } = useSettingsStore.getState();
@@ -808,14 +851,12 @@ export default function Home() {
if (archiveMode === 'single') {
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
} else {
// Determine year/month from the email's received date
const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Find or create year subfolder under archive
let yearMailbox = mailboxes.find(
let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
@@ -826,9 +867,9 @@ export default function Home() {
if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else {
// archiveMode === 'month' - find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = mailboxes.find(
const afterYear = useEmailStore.getState().mailboxes;
let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
@@ -1477,9 +1518,12 @@ export default function Home() {
} else {
selectAllEmails();
}
} else {
// Enter selection mode by selecting the first email
if (emails.length > 0) toggleEmailSelection(emails[0].id);
} else if (emails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && emails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
toggleEmailSelection(target);
}
}}
className={cn(
@@ -1804,7 +1848,8 @@ export default function Home() {
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
@@ -1882,14 +1927,6 @@ export default function Home() {
/>
) : (
<>
{/* Mobile Header for Viewer */}
{isMobile && activeView === "viewer" && (
<MobileViewerHeader
subject={selectedEmail?.subject}
onBack={handleMobileBack}
/>
)}
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
+152 -82
View File
@@ -9,7 +9,6 @@ import {
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
@@ -20,17 +19,27 @@ import {
FolderOpen,
Tags,
HardDrive,
Wrench,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
LayoutGrid,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
import { ComposingSettings } from '@/components/settings/composing-settings';
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { AboutDataSettings } from '@/components/settings/about-data-settings';
import { DebugSettings } from '@/components/settings/debug-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
type Tab =
| 'account'
| 'language'
| 'notifications'
| 'appearance'
| 'layout'
| 'reading'
| 'composing'
| 'identities'
| 'vacation'
| 'filters'
| 'templates'
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
| 'files'
| 'sidebar_apps'
| 'about_data'
| 'themes'
| 'plugins'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
id: Tab;
@@ -74,28 +109,54 @@ interface TabDef {
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
notifications: Bell,
account: User,
security: Shield,
language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen,
encryption: KeyRound,
vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
plugins: Puzzle,
advanced: Wrench,
debug: Bug,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
email: 'reading',
advanced: 'about_data',
};
function readPersistedTab(): Tab {
try {
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
const migrated = LEGACY_TAB_MAP[saved];
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
} catch {
return 'appearance';
}
}
export default function SettingsPage() {
const router = useRouter();
@@ -107,13 +168,7 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
@@ -124,21 +179,28 @@ export default function SettingsPage() {
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Listen for tab change events from child components
// Listen for tab change events from child components (with legacy migration)
useEffect(() => {
const handler = (e: Event) => {
const tab = (e as CustomEvent).detail as Tab;
if (tab) {
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
}
const raw = (e as CustomEvent).detail as string;
if (!raw) return;
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
@@ -161,25 +223,41 @@ export default function SettingsPage() {
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
// General
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// Group tabs by category
@@ -191,6 +269,10 @@ export default function SettingsPage() {
}))
.filter((g) => g.items.length > 0);
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
@@ -199,39 +281,42 @@ export default function SettingsPage() {
}
};
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'notifications' && <NotificationSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'themes' && <ThemesSettings />}
{activeTab === 'plugins' && <PluginsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'templates' && <TemplateSettings />}
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile content header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -244,14 +329,12 @@ export default function SettingsPage() {
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -264,10 +347,8 @@ export default function SettingsPage() {
);
}
// Mobile: show tab list
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -283,7 +364,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{groupedTabs.map((group, groupIndex) => (
@@ -319,7 +399,6 @@ export default function SettingsPage() {
))}
</div>
{/* Logout */}
<div className="border-t border-border px-5 py-3">
<button
onClick={logout}
@@ -331,7 +410,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -347,7 +425,6 @@ export default function SettingsPage() {
// Desktop layout
return (
<div className="flex h-dvh bg-background">
{/* Navigation Rail */}
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -366,7 +443,6 @@ export default function SettingsPage() {
)}
{!inlineApp && (
<>
{/* Settings Sidebar */}
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
@@ -374,7 +450,6 @@ export default function SettingsPage() {
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{/* Header */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
@@ -387,7 +462,6 @@ export default function SettingsPage() {
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
@@ -406,14 +480,14 @@ export default function SettingsPage() {
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
{tab.experimental && (
@@ -430,7 +504,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Sidebar resize handle */}
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
@@ -441,10 +514,8 @@ export default function SettingsPage() {
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
@@ -452,7 +523,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
-1
View File
@@ -122,7 +122,6 @@ export default function AdminSettingsPage() {
</div>
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch auth info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch crypto info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* POST /api/account/stalwart/jmap
*
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
* context so the browser does not need access to the user's credentials.
*
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
*
* In Stalwart 0.16 all management operations (password change, app
* passwords, API keys, account settings, etc.) are exposed as JMAP
* methods under the `x:` namespace on the same endpoint.
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
});
const responseText = await response.text();
return new NextResponse(responseText, {
status: response.status,
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,93 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,96 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch principal' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+22 -4
View File
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
* Permissions that indicate Stalwart admin privileges.
* If the authenticated user has at least one of these, they can manage
* system-level resources and are considered an admin.
*/
const ADMIN_PERMISSIONS = [
'sysAccountQuery',
'sysTenantQuery',
'sysSystemSettingsGet',
];
/**
* Check if the current user is a Stalwart admin by inspecting the
* permissions list returned by Stalwart's /api/account endpoint.
*/
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try {
const creds = await getStalwartCredentials(request);
if (!creds) return false;
// Probe admin-only endpoint: listing principals requires admin privileges
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
const response = await fetch(`${creds.serverUrl}/api/account`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isAdmin = response.ok;
if (!response.ok) {
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
return false;
}
const data = await response.json() as { permissions?: string[] };
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
return isAdmin;
} catch (error) {
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user is a Stalwart admin.
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
// Probe an admin-only endpoint: listing principals requires admin privileges.
// Use limit=1 to minimize payload.
const url = `${creds.apiUrl}/api/principal?limit=1`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isStalwartAdmin = response.ok;
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}
+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>' },
},
},
{
+1 -1
View File
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const baseUrl = creds.serverUrl.replace(/\/$/, '');
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
+5 -1
View File
@@ -209,10 +209,14 @@ body {
}
.email-content-text a {
color: var(--color-primary);
color: #2563eb;
text-decoration: underline;
}
.dark .email-content-text a {
color: #60a5fa;
}
.email-content {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+2
View File
@@ -1,5 +1,7 @@
import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
const appName =
process.env.APP_NAME ||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
timeFormat?: "12h" | "24h";
}
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
timeFormat = "24h",
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
+20 -3
View File
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarDayView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
timeFormat = "24h",
isMobile,
@@ -117,6 +119,7 @@ export function CalendarDayView({
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
isMobile,
});
const formatHour = (h: number): string => {
@@ -157,6 +160,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/>
);
})}
@@ -243,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;
@@ -264,13 +270,24 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
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}
>
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarMonthView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
@@ -279,6 +281,7 @@ export function CalendarMonthView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
</div>
+20 -3
View File
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
@@ -42,6 +43,7 @@ export function CalendarWeekView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
@@ -181,6 +183,7 @@ export function CalendarWeekView({
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
isMobile,
});
const formatHour = (h: number): string => {
@@ -242,6 +245,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/>
</div>
);
@@ -381,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;
@@ -402,13 +408,24 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
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 -1
View File
@@ -17,6 +17,7 @@ interface EventCardProps {
onClick?: (anchorRect: DOMRect) => void;
onMouseEnter?: (anchorRect: DOMRect) => void;
onMouseLeave?: () => void;
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
isSelected?: boolean;
draggable?: boolean;
continuesBefore?: boolean;
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"aria-roledescription": "draggable event",
} : {};
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
@@ -0,0 +1,93 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import {
Pencil,
Copy,
Download,
ClipboardCopy,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import type { CalendarEvent } from "@/lib/jmap/types";
interface Position {
x: number;
y: number;
}
interface EventContextMenuProps {
event: CalendarEvent;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onEdit: () => void;
onDuplicate: () => void;
onExportICS: () => void;
onCopyTitle: () => void;
onCopyMeetingLink?: () => void;
onDelete: () => void;
}
export function EventContextMenu({
event,
position,
isOpen,
onClose,
menuRef,
onEdit,
onDuplicate,
onExportICS,
onCopyTitle,
onCopyMeetingLink,
onDelete,
}: EventContextMenuProps) {
const t = useTranslations("calendar");
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const hasMeetingLink = !!(
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
<ContextMenuSeparator />
<ContextMenuItem
icon={Download}
label={t("events.export_ics")}
onClick={handle(onExportICS)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("events.copy_title")}
onClick={handle(onCopyTitle)}
/>
{hasMeetingLink && onCopyMeetingLink && (
<ContextMenuItem
icon={LinkIcon}
label={t("events.copy_link")}
onClick={handle(onCopyMeetingLink)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("events.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+14 -5
View File
@@ -9,7 +9,7 @@ import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import {
isOrganizer,
getUserParticipantId,
@@ -233,6 +233,7 @@ export function EventModal({
.map(p => ({ name: p.name, email: p.email }));
});
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Report live preview to parent for grid outline
useEffect(() => {
@@ -264,6 +265,9 @@ export function EventModal({
if (!trimmedTitle || isSaving) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
const pendingAttendee = participantInputRef.current?.flush() ?? null;
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
const startStr = allDay
? `${startDate}T00:00:00`
: `${startDate}T${startTime}:00`;
@@ -301,6 +305,10 @@ export function EventModal({
privacy: "public",
};
if (!event) {
data.uid = generateUUID();
}
if (location.trim()) {
data.locations = {
loc1: {
@@ -373,20 +381,20 @@ export function EventModal({
data.alerts = null;
}
if (attendees.length > 0 && currentUserEmails.length > 0) {
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
const organizerEmail = currentUserEmails[0];
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
data.participants = buildParticipantMap(
{ name: organizerName, email: organizerEmail },
attendees
effectiveAttendees
) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` };
} else if (attendees.length === 0 && event?.participants) {
} else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null;
data.replyTo = null;
}
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
setIsSaving(true);
try {
await onSave(data, shouldSendScheduling);
@@ -839,6 +847,7 @@ export function EventModal({
</span>
</label>
<ParticipantInput
ref={participantInputRef}
participants={attendees}
onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee}
+42 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
disabled?: boolean;
}
export interface ParticipantInputHandle {
flush: () => Participant | null;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
const t = useTranslations("calendar.participants");
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]);
@@ -86,8 +90,26 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => {
const trimmed = query.trim();
if (trimmed && EMAIL_REGEX.test(trimmed)) {
addParticipant({ name: "", email: trimmed });
}
setTimeout(() => setShowSuggestions(false), 200);
}, []);
}, [query, addParticipant]);
useImperativeHandle(ref, () => ({
flush: () => {
const trimmed = query.trim();
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
const p = { name: "", email: trimmed };
onAdd(p);
setQuery("");
setSuggestions([]);
setShowSuggestions(false);
return p;
},
}), [query, participants, onAdd]);
return (
<div className="space-y-2">
@@ -143,7 +165,22 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
key={`${p.email}-${i}`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
>
<span className="truncate">{p.name || p.email}</span>
{!disabled ? (
<button
type="button"
onClick={() => {
onRemove(p.email);
setQuery(p.email);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
aria-label={`${t("edit")} ${p.name || p.email}`}
>
{p.name || p.email}
</button>
) : (
<span className="truncate">{p.name || p.email}</span>
)}
{!disabled && (
<button
type="button"
@@ -160,4 +197,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
)}
</div>
);
}
});
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const deleteButton = screen.getAllByRole('button').find(
btn => btn.className.includes('text-red')
);
fireEvent.click(deleteButton!);
fireEvent.click(screen.getByLabelText('detail.more_actions'));
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
expect(onDelete).toHaveBeenCalledOnce();
});
});
@@ -36,7 +36,6 @@ const defaultProps = {
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
categoryLabel: 'All Contacts',
selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(),
@@ -45,6 +44,9 @@ const defaultProps = {
onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(),
onEditContact: vi.fn(),
onDeleteContact: vi.fn(),
onAddContactToGroup: vi.fn(),
};
describe('ContactList', () => {
@@ -76,8 +78,4 @@ describe('ContactList', () => {
expect(screen.getByText('bulk.export')).toBeInTheDocument();
});
it('shows category label with count', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
});
});
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { Avatar } from "@/components/ui/avatar";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
const EVENT_LOOKAHEAD_DAYS = 365;
interface ContactActivityProps {
contact: ContactCard;
}
function getContactEmails(contact: ContactCard): string[] {
if (!contact.emails) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const e of Object.values(contact.emails)) {
const addr = e.address?.trim().toLowerCase();
if (addr && !seen.has(addr)) {
seen.add(addr);
result.push(addr);
}
}
return result;
}
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
for (const addr of addresses) {
conditions.push({ from: addr });
conditions.push({ to: addr });
}
if (conditions.length === 1) return conditions[0];
return { operator: "OR", conditions };
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (event.participants) {
for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
}
}
}
if (event.organizerCalendarAddress) {
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
if (addresses.has(org)) return true;
}
return false;
}
function getEmailSender(email: Email): { name: string; address: string } {
const from = email.from?.[0];
return {
name: from?.name?.trim() || from?.email || "",
address: from?.email || "",
};
}
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const groups = new Map<string, CalendarEvent[]>();
for (const e of events) {
const d = new Date(e.start);
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
const arr = groups.get(key) || [];
arr.push(e);
groups.set(key, arr);
}
return groups;
}
export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity");
const format = useFormatter();
const router = useRouter();
const client = useAuthStore((s) => s.client);
const selectEmail = useEmailStore((s) => s.selectEmail);
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
const [emails, setEmails] = useState<Email[] | null>(null);
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [emailsLoading, setEmailsLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(false);
const [emailsError, setEmailsError] = useState(false);
const [eventsError, setEventsError] = useState(false);
const addresses = getContactEmails(contact);
const addressKey = addresses.join(",");
useEffect(() => {
if (!client || addresses.length === 0) {
setEmails([]);
return;
}
let cancelled = false;
setEmailsLoading(true);
setEmailsError(false);
client
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
.then((res) => {
if (cancelled) return;
setEmails(res.emails);
})
.catch(() => {
if (cancelled) return;
setEmailsError(true);
setEmails([]);
})
.finally(() => {
if (!cancelled) setEmailsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!client || addresses.length === 0) {
setEvents([]);
return;
}
let cancelled = false;
setEventsLoading(true);
setEventsError(false);
const now = new Date();
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
const addrSet = new Set(addresses);
client
.queryAllCalendarEvents(
{ after: now.toISOString(), before: before.toISOString() },
[{ property: "start", isAscending: true }],
500,
)
.then((all) => {
if (cancelled) return;
const matching = all
.filter((e) => eventInvolvesContact(e, addrSet))
.sort((a, b) => a.start.localeCompare(b.start))
.slice(0, EVENT_LIMIT);
setEvents(matching);
})
.catch(() => {
if (cancelled) return;
setEventsError(true);
setEvents([]);
})
.finally(() => {
if (!cancelled) setEventsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (addresses.length === 0) return null;
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
};
const handleOpenEvent = (event: CalendarEvent) => {
const start = new Date(event.start);
if (!isNaN(start.getTime())) setSelectedDate(start);
setSelectedEventId(event.id);
router.push("/calendar");
};
const formatEmailDate = (dateStr: string) => {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { month: "short", day: "numeric" }
: { year: "numeric", month: "short", day: "numeric" });
};
const formatEventTime = (event: CalendarEvent) => {
if (event.showWithoutTime) return t("all_day");
const d = new Date(event.start);
if (isNaN(d.getTime())) return "";
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
};
const formatEventDateHeader = (isoDate: string) => {
const d = new Date(isoDate);
if (isNaN(d.getTime())) return isoDate;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { weekday: "long", month: "long", day: "numeric" }
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
};
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
return (
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<Section title={t("recent_emails")}>
{emailsLoading ? (
<LoadingRow />
) : emailsError ? (
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !emails || emails.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
) : (
<div className="-mx-2">
{emails.map((email) => {
const sender = getEmailSender(email);
return (
<button
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<Avatar name={sender.name} email={sender.address} size="sm" />
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{sender.name || t("unknown_sender")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
</span>
</div>
<div className="text-sm truncate">
{email.subject || t("no_subject")}
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</div>
</button>
);
})}
</div>
)}
</Section>
<Section title={t("upcoming_events")}>
{eventsLoading ? (
<LoadingRow />
) : eventsError ? (
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !events || events.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
) : (
<div className="space-y-4">
{eventGroups.map(([dateKey, group]) => (
<div key={dateKey}>
<div className="text-xs font-medium text-muted-foreground mb-1.5">
{formatEventDateHeader(dateKey)}
</div>
<div className="-mx-2">
{group.map((event) => (
<button
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
{formatEventTime(event)}
</span>
<span className="text-sm truncate flex-1 min-w-0">
{event.title || t("no_title")}
</span>
</button>
))}
</div>
</div>
))}
</div>
)}
</Section>
</div>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
Eye,
Pencil,
Mail,
Phone,
ClipboardCopy,
Download,
Users,
Trash2,
Copy,
Printer,
} from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
if (!contact.phones) return "";
return Object.values(contact.phones)[0]?.number || "";
}
interface Position {
x: number;
y: number;
}
interface ContactContextMenuProps {
contact: ContactCard;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
isMultiSelect?: boolean;
selectedCount?: number;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onDuplicate?: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
}
export function ContactContextMenu({
contact,
position,
isOpen,
onClose,
menuRef,
isMultiSelect = false,
selectedCount = 1,
onOpen,
onEdit,
onDelete,
onAddToGroup,
onDuplicate,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const handleSendEmail = () => {
if (!email) return;
window.location.href = `mailto:${email}`;
};
const handleCall = () => {
if (!phone) return;
window.location.href = `tel:${phone}`;
};
const handleCopy = async (value: string) => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
};
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact);
};
if (showBatchActions) {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>
{t("bulk.selected", { count: selectedCount })}
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
onClick={handle(() => onBatchExport?.())}
disabled={!onBatchExport}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("bulk.delete")}
onClick={handle(() => onBatchDelete?.())}
disabled={!onBatchDelete}
destructive
/>
</ContextMenu>
);
}
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && (
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
)}
{phone && (
<ContextMenuItem
icon={Phone}
label={t("context_menu.call")}
onClick={handle(handleCall)}
/>
)}
{email && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(() => handleCopy(email))}
/>
)}
{phone && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_phone")}
onClick={handle(() => handleCopy(phone))}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
onClick={handle(handleExport)}
/>
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+434 -290
View File
@@ -1,22 +1,37 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
isMobile?: boolean;
className?: string;
}
@@ -26,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
const m = month ?? 1;
const d = day ?? 1;
const nowM = now.getMonth() + 1;
const nowD = now.getDate();
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
if (years < 0) return null;
return years;
}
function formatDate(dateInput: AnniversaryDate): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try {
@@ -40,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
return String(dateInput.utc);
}
const pd = dateInput as PartialDate;
const year = pd.year;
const month = pd.month;
const day = pd.day;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
if (day) parts.push(String(day));
if (year) parts.push(String(year));
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (pd.day) parts.push(String(pd.day));
if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
@@ -69,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
@@ -87,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
// data URI - extract base64 content
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
@@ -96,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
// PEM-encoded certificate inline
derBytes = key.uri;
}
if (!derBytes) continue;
@@ -127,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const photoUri = getContactPhotoUri(contact);
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact, name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
@@ -170,255 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="flex items-center gap-4 min-w-0 flex-1">
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&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">
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
{hasContactDetails && (
<Section title={t("detail.section_contact")}>
<div className="space-y-3">
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
{e.contexts && <ContextBadge contexts={e.contexts} />}
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
{e.address}
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
<RowActions>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</div>
</FieldRow>
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => {
const featureStr = formatPhoneFeatures(p.features);
const features = formatPhoneFeatures(p.features);
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
return (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && <ContextBadge contexts={p.contexts} />}
{featureStr && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
)}
<CopyButton
value={p.number}
label={t("detail.copy_phone")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
<RowActions>
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
);
})}
</Section>
)}
{(roles.length > 0 || jobTitles.length > 1) && (
<Section icon={Briefcase} title={t("detail.titles")} category="work">
{titles.map((tl, i) => (
<div key={i} className="text-sm flex items-center gap-2">
<span>{tl.name}</span>
{tl.kind && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
)}
</div>
))}
</Section>
)}
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")} category="work">
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</Section>
)}
{/* Addresses span full width */}
{addresses.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={MapPin} title={t("detail.addresses")} category="location">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{addresses.map((a, i) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{a.full || a.fullAddress
? (a.full || a.fullAddress)
: a.components && a.components.length > 0
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && <ContextBadge contexts={a.contexts} />}
</div>
{addresses.map((a, i) => {
const lines: string[] = [];
if (a.full || a.fullAddress) {
lines.push((a.full || a.fullAddress) as string);
} else if (a.components && a.components.length > 0) {
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
if (joined) lines.push(joined);
} else {
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
.map(s => (typeof s === "string" ? s.trim() : ""))
.filter(Boolean) as string[];
lines.push(...parts);
}
return (
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
<div className="text-sm space-y-0.5">
{lines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)}
</div>
))}
</div>
</Section>
</div>
)}
</FieldRow>
);
})}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
)}
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
<CopyButton
value={svc.user || svc.uri}
label={t("detail.copy_url")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
<FieldRow
key={`os${i}`}
icon={Globe}
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
>
<div className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
<RowActions>
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
))}
</Section>
)}
</div>
</Section>
)}
{anniversaries.length > 0 && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{formatDate(ann.date)}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{t(`detail.anniversary_${ann.kind}`)}
</span>
</div>
{hasWork && (
<Section title={t("detail.section_work")}>
<div className="space-y-3">
{orgs.map((o, i) => (
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
<div className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
</FieldRow>
))}
</Section>
)}
{personalInfo.length > 0 && (
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
{personalInfo.map((pi, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{pi.value}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
{pi.level && (
<span className="text-xs text-muted-foreground">({pi.level})</span>
)}
</div>
{titles.map((tl, i) => (
<FieldRow
key={`tl${i}`}
icon={Briefcase}
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
>
<div className="text-sm">{tl.name}</div>
</FieldRow>
))}
</Section>
)}
</div>
</Section>
)}
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
<div className="text-sm">
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
{contact.speakToAs.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</Section>
)}
{preferredLanguages.length > 0 && (
<Section icon={Languages} title={t("detail.languages")} category="personal">
{hasPersonal && (
<Section title={t("detail.section_personal")}>
<div className="space-y-3">
{anniversaries.map((ann, i) => {
const years = getCompletedYears(ann.date);
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
return (
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
<div className="text-sm">
{formatDate(ann.date)}
{years !== null && (
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)}
</div>
</FieldRow>
);
})}
{hasGender && (
<FieldRow icon={UserCircle} label={t("detail.gender")}>
<div className="text-sm">
{contact.speakToAs?.grammaticalGender && (
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
)}
{contact.speakToAs?.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</FieldRow>
)}
{preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{lang.language}</span>
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
</div>
<FieldRow
key={`lg${i}`}
icon={Languages}
label={formatContexts(lang.contexts) || t("detail.language_label")}
>
<div className="text-sm">{lang.language}</div>
</FieldRow>
))}
</Section>
)}
{personalInfo.map((pi, i) => (
<FieldRow
key={`pi${i}`}
icon={Heart}
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
>
<div className="text-sm">{pi.value}</div>
</FieldRow>
))}
</div>
</Section>
)}
{keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital">
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
</Section>
)}
{keywords.length > 0 && (
<Section title={t("detail.categories")}>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
</Section>
)}
{relatedTo.length > 0 && (
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
{relatedTo.length > 0 && (
<Section title={t("detail.related_contacts")}>
<div className="space-y-2">
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{uri}</span>
{relType && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
<div className="text-sm break-all">{uri}</div>
</FieldRow>
);
})}
</Section>
)}
</div>
</Section>
)}
{cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{cryptoKeys.map((key, i) => {
const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
@@ -427,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
: false;
return (
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
@@ -448,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button
variant="ghost"
size="sm"
className="ml-4 mt-1"
onClick={() => handleImportContactCert(i)}
>
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
@@ -463,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
)}
</>
) : (
<div className="text-sm break-all">
<div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
@@ -476,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</div>
);
})}
</Section>
)}
</div>
</Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section title={t("detail.calendar")}>
<div className="space-y-3">
{contact.calendarUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.calendarUri}
</a>
</FieldRow>
)}
{contact.schedulingUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.schedulingUri}
</a>
</FieldRow>
)}
{contact.freeBusyUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.freeBusyUri}
</a>
</FieldRow>
)}
</Section>
)}
</div>
</Section>
)}
{/* Notes span full width */}
{notes.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
{notes.length > 0 && (
<Section title={t("detail.notes")}>
<div className="flex items-start gap-3">
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="text-sm space-y-2 flex-1 min-w-0">
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
))}
</Section>
</div>
</div>
)}
</Section>
)}
{/* Timestamps span full width */}
{(contact.created || contact.updated) && (
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
<ContactActivity contact={contact} />
{(contact.created || contact.updated) && (
<div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
</div>
</div>
);
}
function formatContexts(contexts?: Record<string, boolean>): string {
if (!contexts) return "";
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
}
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn("py-6", className)}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
{children}
</section>
);
}
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
{children}
</div>
</div>
);
}
function RowActions({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{children}
</div>
);
}
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((o) => !o)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
className="touch-manipulation"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
>
{items.map((item, i) => {
if (item.separator) {
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
}
return (
<button
key={i}
type="button"
role="menuitem"
onClick={() => {
item.onClick();
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
<span className="flex-1">{item.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const categoryStyles: Record<SectionCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1.5 pl-6">{children}</div>
</div>
);
}
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
const labels = Object.keys(contexts).filter(k => contexts[k]);
if (labels.length === 0) return null;
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
{labels.join(", ")}
</span>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
@@ -572,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
toast.error(failMsg);
}
}}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
title={label}
aria-label={label}
>
+182 -65
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,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4">
<div className="px-6 py-4 max-w-3xl">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* Address Book Selector */}
{addressBooks && addressBooks.length > 1 && (
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
<div className="flex items-center gap-4 pb-4">
<button
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
title={t("upload_photo")}
aria-label={t("upload_photo")}
>
<Avatar
name={previewName || undefined}
email={previewEmail || undefined}
contactPhotoUri={photoUri}
size="lg"
className="!w-20 !h-20 !text-xl"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
>
<Camera className="w-5 h-5" />
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
</span>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoSelect}
/>
</button>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
{photoError && (
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
)}
{photoUri && (
<button
type="button"
onClick={handlePhotoRemove}
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
<Trash2 className="w-3 h-3" />
{t("remove_photo")}
</button>
)}
</div>
</div>
<div className="divide-y divide-border/60">
{addressBooks && addressBooks.length > 1 && (
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
)}
{/* Name & Identity - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<FormSection icon={User} title={t("section_identity")}>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</div>
</FormSection>
</div>
{/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
<FormSection icon={Mail} title={t("email")}>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
<FormSection icon={Phone} title={t("phone")}>
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Addresses - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
{/* Addresses */}
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</Button>
</div>
</FormSection>
</div>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
<div className="space-y-2">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2">
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Notes - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
{/* Notes */}
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -876,7 +994,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</FormSection>
</div>
</div>
</div>
+3 -1
View File
@@ -17,9 +17,10 @@ interface ContactListItemProps {
selectedContactIds: Set<string>;
onClick: (e: React.MouseEvent) => void;
onCheckboxClick: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
}
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const org = contact.organizations
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
draggable
onDragStart={handleDragStart}
onClick={onClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
className={cn(
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
isSelected
+438 -56
View File
@@ -1,16 +1,98 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
type TriState = boolean | null;
interface ListFilters {
organization: string;
jobTitle: string;
location: string;
emailDomain: string;
birthdayMonth: number | null;
hasEmail: TriState;
hasPhone: TriState;
hasPhoto: TriState;
}
const EMPTY_FILTERS: ListFilters = {
organization: "",
jobTitle: "",
location: "",
emailDomain: "",
birthdayMonth: null,
hasEmail: null,
hasPhone: null,
hasPhoto: null,
};
function cycleTri(v: TriState): TriState {
return v === null ? true : v === true ? false : null;
}
function countActiveFilters(f: ListFilters): number {
let n = 0;
if (f.organization.trim()) n++;
if (f.jobTitle.trim()) n++;
if (f.location.trim()) n++;
if (f.emailDomain.trim()) n++;
if (f.birthdayMonth !== null) n++;
if (f.hasEmail !== null) n++;
if (f.hasPhone !== null) n++;
if (f.hasPhoto !== null) n++;
return n;
}
function matchTri(actual: boolean, filter: TriState): boolean {
if (filter === null) return true;
return actual === filter;
}
function getAnniversaryMonth(date: AnniversaryDate): number | null {
if (typeof date === "string") {
const iso = date.match(/^(\d{4})-(\d{2})/);
if (iso) return parseInt(iso[2], 10);
const partial = date.match(/^--(\d{2})/);
if (partial) return parseInt(partial[1], 10);
return null;
}
if ("month" in date && date.month) return date.month;
if ("utc" in date && date.utc) {
const d = new Date(date.utc);
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
}
return null;
}
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
value === true && "bg-primary/10 border-primary/30 text-primary",
value === false && "bg-muted border-border text-muted-foreground line-through",
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
)}
>
{icon}
{label}
</button>
);
}
interface ContactListProps {
contacts: ContactCard[];
selectedContactId: string | null;
@@ -18,7 +100,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
categoryLabel: string;
className?: string;
selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void;
@@ -28,6 +109,9 @@ interface ContactListProps {
onBulkDelete: () => void;
onBulkAddToGroup: () => void;
onBulkExport: () => void;
onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void;
}
export function ContactList({
@@ -37,7 +121,6 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
categoryLabel,
className,
selectedContactIds,
onToggleSelection,
@@ -47,32 +130,99 @@ export function ContactList({
onBulkDelete,
onBulkAddToGroup,
onBulkExport,
onEditContact,
onDeleteContact,
onAddContactToGroup,
}: ContactListProps) {
const t = useTranslations("contacts");
const locale = useLocale();
const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
const monthNames = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
}, [locale]);
const filtered = useMemo(() => {
if (!searchQuery) return contacts;
const lower = searchQuery.toLowerCase();
const lower = searchQuery.trim().toLowerCase();
const orgLower = filters.organization.trim().toLowerCase();
const jobLower = filters.jobTitle.trim().toLowerCase();
const locLower = filters.location.trim().toLowerCase();
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
return contacts.filter((c) => {
const emails = c.emails ? Object.values(c.emails) : [];
const phones = c.phones ? Object.values(c.phones) : [];
const orgs = c.organizations ? Object.values(c.organizations) : [];
const titles = c.titles ? Object.values(c.titles) : [];
const addresses = c.addresses ? Object.values(c.addresses) : [];
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
if (orgLower) {
const match = orgs.some((o) => {
if (o.name?.toLowerCase().includes(orgLower)) return true;
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
return false;
});
if (!match) return false;
}
if (jobLower) {
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
}
if (locLower) {
const match = addresses.some((a) => {
const parts: string[] = [];
if (a.full) parts.push(a.full);
if (a.fullAddress) parts.push(a.fullAddress);
if (a.locality) parts.push(a.locality);
if (a.region) parts.push(a.region);
if (a.country) parts.push(a.country);
if (a.postcode) parts.push(a.postcode);
if (a.street) parts.push(a.street);
if (a.components) {
for (const comp of a.components) {
if (comp.value) parts.push(comp.value);
}
}
return parts.some((p) => p.toLowerCase().includes(locLower));
});
if (!match) return false;
}
if (domainLower) {
const match = emails.some((e) => {
const at = e.address?.toLowerCase().split("@");
return at && at.length > 1 && at[1].includes(domainLower);
});
if (!match) return false;
}
if (filters.birthdayMonth !== null) {
const target = filters.birthdayMonth;
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
if (!match) return false;
}
if (!lower) return true;
const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails
? Object.values(c.emails).map((e) => e.address.toLowerCase())
: [];
const phones = c.phones
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
: [];
const org = c.organizations
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
: [];
return (
name.includes(lower) ||
emails.some((e) => e.includes(lower)) ||
phones.some((p) => p.includes(lower)) ||
org.some((o) => o.includes(lower))
);
if (name.includes(lower)) return true;
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
return false;
});
}, [contacts, searchQuery]);
}, [contacts, searchQuery, filters]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
@@ -84,29 +234,214 @@ export function ContactList({
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
// Group sorted contacts by first letter of display name. Non-letter
// starters (digits, symbols, empty) collect under "#" which sorts last.
const groupedSections = useMemo(() => {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
const groups = new Map<string, ContactCard[]>();
for (const contact of sorted) {
const name = getContactDisplayName(contact).trim();
const first = name.charAt(0);
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
? first.toLocaleUpperCase(locale)
: "#";
const bucket = groups.get(letter);
if (bucket) bucket.push(contact);
else groups.set(letter, [contact]);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
if (a === "#") return 1;
if (b === "#") return -1;
return collator.compare(a, b);
})
.map(([letter, items]) => ({ letter, items }));
}, [sorted, locale]);
const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Search header */}
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground truncate">
{categoryLabel} ({contacts.length})
</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8 h-8 text-sm"
/>
{/* Toolbar: select / search / filter */}
<div className="border-b border-border bg-background">
<div className="px-3 py-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
if (hasSelection) {
if (allSelected) onClearSelection();
else onSelectAll(sortedIds);
} else if (sortedIds.length > 0) {
const target = selectedContactId && sortedIds.includes(selectedContactId)
? selectedContactId
: sortedIds[0];
onToggleSelection(target);
}
}}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
hasSelection
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
>
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
/>
{searchQuery && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
filtersOpen || activeFilters > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("filters.toggle")}
aria-label={t("filters.toggle")}
>
<Filter className="w-4 h-4" />
{!filtersOpen && activeFilters > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{activeFilters}
</span>
)}
</button>
</div>
</div>
</div>
{filtersOpen && (
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
{t("filters.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setFiltersOpen(false)}
className="h-7 w-7"
aria-label={t("filters.close")}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
<Input
value={filters.organization}
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
placeholder={t("filters.organization_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
<Input
value={filters.jobTitle}
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
placeholder={t("filters.job_title_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
<Input
value={filters.location}
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
placeholder={t("filters.location_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
<Input
value={filters.emailDomain}
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
placeholder={t("filters.email_domain_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
<select
value={filters.birthdayMonth ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
aria-label={t("filters.birthday_month")}
>
<option value="">{t("filters.any_month")}</option>
{monthNames.map((name, i) => (
<option key={i} value={i + 1}>{name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ToggleChip
icon={<Mail className="w-3.5 h-3.5" />}
label={t("filters.has_email")}
value={filters.hasEmail}
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
/>
<ToggleChip
icon={<Phone className="w-3.5 h-3.5" />}
label={t("filters.has_phone")}
value={filters.hasPhone}
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
/>
<ToggleChip
icon={<ImageIcon className="w-3.5 h-3.5" />}
label={t("filters.has_photo")}
value={filters.hasPhoto}
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
/>
</div>
</div>
</div>
)}
{/* Bulk action bar */}
{hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
@@ -157,19 +492,32 @@ export function ContactList({
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery ? (
{searchQuery || activeFilters > 0 ? (
<>
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => onSearchChange("")}
>
{t("clear_search")}
</Button>
{activeFilters > 0 && !searchQuery ? (
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
) : (
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
)}
<p className="text-sm font-medium text-foreground">
{searchQuery ? t("empty_search") : t("empty_filtered")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
</p>
<div className="flex gap-2 mt-3">
{searchQuery && (
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
{t("clear_search")}
</Button>
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
{t("filters.clear")}
</Button>
)}
</div>
</>
) : (
<>
@@ -184,8 +532,8 @@ export function ContactList({
)}
</div>
) : (
<div>
{sorted.map((contact) => (
(() => {
const renderItem = (contact: ContactCard) => (
<ContactListItem
key={contact.id}
contact={contact}
@@ -210,11 +558,45 @@ export function ContactList({
e.stopPropagation();
onToggleSelection(contact.id);
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
/>
))}
</div>
);
return groupByLetter ? (
<div>
{groupedSections.map(({ letter, items }) => (
<section key={letter}>
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
{letter}
</div>
{items.map(renderItem)}
</section>
))}
</div>
) : (
<div>{sorted.map(renderItem)}</div>
);
})()
)}
</div>
{contextMenu.data && (
<ContactContextMenu
contact={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
selectedCount={selectedContactIds.size}
onOpen={() => onSelectContact(contextMenu.data!.id)}
onEdit={() => onEditContact(contextMenu.data!.id)}
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
/>
)}
</div>
);
}
+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>
);
}
+132 -30
View File
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
@@ -66,7 +66,7 @@ interface EmailComposerProps {
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
@@ -86,9 +86,21 @@ interface EmailComposerProps {
htmlBody?: string;
receivedAt?: string;
accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
};
}
type ComposerAttachment = {
file?: File;
name: string;
type: string;
size: number;
blobId?: string;
uploading?: boolean;
error?: boolean;
abortController?: AbortController;
};
export function EmailComposer({
onSend,
onClose,
@@ -201,7 +213,22 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
// (matches the viewer's hideInlineImageAttachments logic).
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
}
return [];
});
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
@@ -324,7 +351,7 @@ export function EmailComposer({
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
@@ -523,9 +550,16 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
const newAttachments = files.map(file => {
const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController();
return { file, uploading: true, abortController: controller };
return {
file,
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
uploading: true,
abortController: controller,
};
});
setAttachments(prev => [...prev, ...newAttachments]);
@@ -560,18 +594,38 @@ export function EmailComposer({
}
}, [client, t]);
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => {
debug.error(`Failed to read inline image ${file.name}`);
toast.error(t('upload_failed', { filename: file.name }));
resolve(null);
};
reader.readAsDataURL(file);
});
}, [t]);
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const [{ blobId }, dataUrl] = await Promise.all([
client.uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
@@ -648,9 +702,9 @@ export function EmailComposer({
.filter(att => att.blobId && !att.uploading)
.map(att => ({
blobId: att.blobId!,
name: att.file.name,
type: att.file.type,
size: att.file.size,
name: att.name,
type: att.type,
size: att.size,
}));
// Create a hash of current data to compare with last saved
@@ -751,6 +805,41 @@ export function EmailComposer({
return undefined;
};
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): {
html: string;
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => {
const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
});
return {
html: doc.body.innerHTML,
attachments: Array.from(used.values()).map((e) => ({
blobId: e.blobId,
name: e.name,
type: e.type,
size: e.size,
disposition: 'inline' as const,
cid: e.cid,
})),
};
};
const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -821,9 +910,11 @@ export function EmailComposer({
? appendPlainTextSignature(body, currentIdentity)
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
? undefined
: `<div>${body}</div>${buildSignatureHtml()}`;
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
const inlineAttachments = rewritten?.attachments ?? [];
try {
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
@@ -852,19 +943,29 @@ export function EmailComposer({
for (const att of attachments) {
if (att.error || att.uploading) continue;
let content: ArrayBuffer;
if (att.file.size > 0) {
if (att.file && att.file.size > 0) {
content = await att.file.arrayBuffer();
} else if (att.blobId && client) {
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
} else {
continue;
}
mimeAttachments.push({
filename: att.file.name,
contentType: att.file.type || 'application/octet-stream',
filename: att.name,
contentType: att.type || 'application/octet-stream',
content,
});
}
for (const inline of inlineAttachments) {
if (!client) break;
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
mimeAttachments.push({
filename: inline.name,
contentType: inline.type,
content,
cid: inline.cid,
});
}
// 4. Build canonical MIME
const mimeBytes = buildMimeMessage({
@@ -924,9 +1025,10 @@ export function EmailComposer({
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
const uploadedAttachments = attachments
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
.filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
await onSend?.({
to: toAddresses,
@@ -1306,9 +1408,9 @@ export function EmailComposer({
) : (
<Paperclip className="w-3 h-3 flex-shrink-0" />
)}
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)})
({formatFileSize(att.size)})
</span>
<button
onClick={() => removeAttachment(index)}
+6 -2
View File
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchArchive?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
@@ -127,6 +128,7 @@ export function EmailContextMenu({
onUndoSpam,
onBatchMarkAsRead,
onBatchDelete,
onBatchArchive,
onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
@@ -235,8 +237,10 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
onClick={() =>
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
}
disabled={showBatchActions ? !onBatchArchive : !onArchive}
/>
{/* Delete */}
+3 -2
View File
@@ -99,7 +99,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
@@ -302,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
+9
View File
@@ -69,6 +69,7 @@ export function EmailList({
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
batchArchive,
batchMarkAsSpam,
batchUndoSpam,
loadMoreEmails,
@@ -496,6 +497,14 @@ export function EmailList({
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
if (!client) return;
try {
await batchArchive(client);
} catch (error) {
console.error('Failed to batch archive:', error);
}
}}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
if (client) {
+52 -58
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 } 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";
@@ -896,6 +896,7 @@ export function EmailViewer({
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -2074,14 +2075,16 @@ export function EmailViewer({
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (smimeDecryptedAttachments.length > 0) {
return smimeDecryptedAttachments.map((attachment, index) => ({
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
name: attachment.filename,
type: attachment.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(attachment),
cid: attachment.contentId,
decryptedAttachment: attachment,
}));
return smimeDecryptedAttachments
.filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
.map((attachment, index) => ({
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
name: attachment.filename,
type: attachment.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(attachment),
cid: attachment.contentId,
decryptedAttachment: attachment,
}));
}
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
@@ -2093,6 +2096,9 @@ export function EmailViewer({
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
// banner is shown - prevents raw ICS files appearing as spurious attachments.
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
// Hide inline cid-referenced images when the user has opted to keep them
// out of the attachment list (default on): these are embedded in the body.
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null,
@@ -2123,7 +2129,7 @@ export function EmailViewer({
}));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled]);
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
// Generate email source for viewing
const generateEmailSource = (email: Email): string => {
@@ -2316,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();
@@ -2397,16 +2403,8 @@ export function EmailViewer({
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const textContent = email.bodyValues[email.textBody[0].partId].value;
// Convert plain text to HTML with proper formatting
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
const htmlFromText = textContent
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return {
html: htmlFromText,
html: plainTextToSafeHtml(textContent),
isHtml: false
};
}
@@ -2440,42 +2438,27 @@ 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) {
const htmlFromText = smimeDecryptedText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
}
// 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) {
const htmlFromText = tnefText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
}
// 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) {
const htmlFromText = embeddedEmailText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -2620,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; }
@@ -2897,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"
@@ -3645,7 +3628,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -3693,8 +3676,8 @@ export function EmailViewer({
)}
</div>
</div>
{/* Date/time on the right of subject row */}
<div className="flex-shrink-0 text-right">
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-right">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
@@ -4501,6 +4484,17 @@ export function EmailViewer({
)}
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
@@ -4791,61 +4785,61 @@ export function EmailViewer({
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.previous')}
>
<ChevronLeft className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
</button>
{isDraft && onEditDraft ? (
<button
onClick={() => onEditDraft()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
<EditIcon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
</button>
) : (
<>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
</button>
<button
onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')}
>
<ReplyAll className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
</button>
<button
onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')}
>
<Forward className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
</button>
</>)}
<button
onClick={onNavigateNext}
disabled={!onNavigateNext}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.next')}
>
<ChevronRight className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
</button>
</div>
</nav>
+5
View File
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
alt: { default: null },
title: { default: null },
width: { default: null },
cid: {
default: null,
parseHTML: (el) => el.getAttribute("data-cid"),
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
},
};
},
+12 -7
View File
@@ -31,10 +31,15 @@ import {
Heading2,
} from "lucide-react";
export interface InlineImageUpload {
src: string;
cid?: string;
}
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
onImageUpload?: (file: File) => Promise<string | null>;
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
placeholder?: string;
className?: string;
hasError?: boolean;
@@ -120,11 +125,11 @@ export function RichTextEditor({
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
upload(file).then((result) => {
if (result) {
const { state } = view;
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
view.dispatch(tr);
}
@@ -141,10 +146,10 @@ export function RichTextEditor({
if (imageFiles.length === 0) return false;
event.preventDefault();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
upload(file).then((result) => {
if (result) {
const { state } = view;
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
const tr = state.tr.replaceSelectionWith(node);
view.dispatch(tr);
}
+11 -10
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
@@ -237,6 +237,7 @@ function EmailCard({
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen;
@@ -419,12 +420,7 @@ function EmailCard({
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
const htmlEscaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
return { html: htmlEscaped, isHtml: false };
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
}
}
@@ -549,10 +545,14 @@ function EmailCard({
</div>
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
{(() => {
const visibleAttachments = (email.attachments ?? []).filter(
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
);
return visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => {
{visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
@@ -581,7 +581,8 @@ function EmailCard({
})}
</div>
</div>
)}
);
})()}
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
+6 -4
View File
@@ -133,7 +133,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "bg-background"
),
selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
@@ -323,7 +324,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
@@ -487,7 +488,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
@@ -725,7 +727,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
+1 -1
View File
@@ -223,7 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
sandbox="allow-same-origin"
sandbox="allow-scripts"
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
+25 -13
View File
@@ -7,7 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link } from "@/i18n/navigation";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -18,7 +18,7 @@ import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -163,6 +163,7 @@ export function NavigationRail({
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
const router = useRouter();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore();
@@ -223,11 +224,12 @@ export function NavigationRail({
let cancelled = false;
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
apiFetch('/api/admin/stalwart-check', { headers })
apiFetch('/api/admin/auth', { headers })
.then(res => res.json())
.then(data => {
if (!cancelled && data.isStalwartAdmin) {
setIsStalwartAdmin(true);
if (cancelled || !data.stalwartAdmin) return;
setIsStalwartAdmin(true);
if (!data.authenticated) {
// Pre-create admin session so /admin works even after full page navigation
apiFetch('/api/admin/auth', {
method: 'POST',
@@ -275,7 +277,7 @@ export function NavigationRail({
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
@@ -294,7 +296,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t(item.labelKey)}</span>
</Link>
);
})}
@@ -316,7 +318,7 @@ export function NavigationRail({
}
}}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
@@ -329,7 +331,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{app.name}</span>
</button>
);
})}
@@ -339,13 +341,13 @@ export function NavigationRail({
<NextLink
href="/admin"
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
"text-muted-foreground hover:text-foreground"
)}
>
<Shield className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink>
)}
@@ -354,7 +356,7 @@ export function NavigationRail({
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isSettingsActive
? "text-primary"
@@ -368,7 +370,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("settings")}</span>
</Link>
</nav>
);
@@ -605,6 +607,16 @@ export function NavigationRail({
</button>
);
})}
{accounts.length < MAX_ACCOUNTS && (
<button
onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
title={t("add_account")}
aria-label={t("add_account")}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Logout button with popover */}
@@ -7,22 +7,21 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() {
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -86,7 +85,6 @@ export function AdvancedSettings() {
return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
@@ -122,88 +120,48 @@ export function AdvancedSettings() {
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
)}
<SettingsSection title={t('title')} description={t('description')}>
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Sender Favicons (Experimental) */}
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Export Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
)}
{/* Import Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</>
</SettingItem>
)}
</SettingItem>
)}
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button>
</>
</SettingItem>
)}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
</>
);
}
+300 -150
View File
@@ -1,12 +1,14 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
@@ -172,37 +174,95 @@ function DisplayNameSection() {
);
}
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'Stalwart',
label: accountLabel || 'account',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 }),
});
return { totp, url: totp.toString() };
}
function TotpSection() {
const t = useTranslations('settings.security');
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [totpUrl, setTotpUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { client } = useAuthStore();
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
useEffect(() => {
if (!setupUrl) { setQrDataUrl(null); return; }
let cancelled = false;
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
.then((url) => { if (!cancelled) setQrDataUrl(url); })
.catch(() => { /* ignore */ });
return () => { cancelled = true; };
}, [setupUrl]);
const startSetup = () => {
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
setSetupTotp(totp);
setSetupUrl(url);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const cancelSetup = () => {
setSetupTotp(null);
setSetupUrl(null);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const confirmSetup = async () => {
if (!setupTotp || !setupUrl) return;
if (!password) { setSetupError(t('totp.password_required')); return; }
if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; }
if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) {
setSetupError(t('totp.code_invalid'));
return;
}
const handleToggle = async (enable: boolean) => {
try {
if (enable) {
const url = await enableTotp();
setTotpUrl(url);
toast.success(t('totp.enabled'));
} else {
await disableTotp();
setTotpUrl(null);
toast.success(t('totp.disabled'));
}
await enableTotp(password, setupUrl, otpCode.trim());
cancelSetup();
toast.success(t('totp.enabled'));
} catch (err) {
toast.error(
enable ? t('totp.enable_error') : t('totp.disable_error'),
err instanceof Error ? err.message : undefined
);
setSetupError(err instanceof Error ? err.message : t('totp.enable_error'));
}
};
const handleCopyUrl = () => {
if (totpUrl) {
navigator.clipboard.writeText(totpUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
const handleDisable = async () => {
if (!password) { setSetupError(t('totp.password_required')); return; }
try {
await disableTotp(password);
setDisableOpen(false);
setPassword('');
setSetupError(null);
toast.success(t('totp.disabled'));
} catch (err) {
setSetupError(err instanceof Error ? err.message : t('totp.disable_error'));
}
};
const handleToggle = (enable: boolean) => {
setSetupError(null);
if (enable) {
startSetup();
} else {
setDisableOpen(true);
setPassword('');
}
};
@@ -218,30 +278,65 @@ function TotpSection() {
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={otpEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<ToggleSwitch
checked={otpEnabled || !!setupUrl}
onChange={handleToggle}
disabled={isSaving}
/>
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')}
</span>
</div>
</SettingItem>
{totpUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
{setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
</div>
)}
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
{totpUrl}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
</div>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
</div>
</div>
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('password.current')}
autoComplete="current-password"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
{t('app_passwords.cancel')}
</Button>
</div>
</div>
@@ -250,58 +345,113 @@ function TotpSection() {
);
}
function AppPasswordsSection() {
const t = useTranslations('settings.security');
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newPassword, setNewPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
function parseIpList(raw: string): string[] {
return raw
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
const generatePassword = useCallback(() => {
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let result = '';
const array = new Uint8Array(24);
crypto.getRandomValues(array);
for (const byte of array) {
result += chars[byte % chars.length];
}
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
return result.match(/.{1,4}/g)?.join('-') ?? result;
}, []);
function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
return (
<div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm text-foreground truncate">{entry.description || entry.id}</span>
{entry.createdAt && (
<span className="text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
</span>
)}
{entry.allowedIps.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{entry.allowedIps.map((ip) => (
<span
key={ip}
className="text-[10px] font-mono bg-background border border-border rounded px-1.5 py-0.5 text-muted-foreground"
>
{ip}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(entry.id)}
disabled={isSaving}
className="text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
}
interface CredentialSectionProps {
icon: typeof Smartphone;
i18nNamespace: 'app_passwords' | 'api_keys';
entries: Array<AppPasswordInfo | ApiKeyInfo>;
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
onRemove: (id: string) => Promise<void>;
}
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
const t = useTranslations('settings.security');
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newDescription, setNewDescription] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
const password = newPassword || generatePassword();
if (!newDescription.trim()) return;
try {
await addAppPassword(newName.trim(), password);
setNewName('');
setNewPassword('');
const result = await onCreate({
description: newDescription.trim(),
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
allowedIps: parseIpList(allowedIpsRaw),
});
setCreatedSecret(result.secret);
setNewDescription('');
setExpiresAt('');
setAllowedIpsRaw('');
setShowAdd(false);
toast.success(t('app_passwords.added'));
toast.success(tk('added'));
} catch (err) {
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
toast.error(tk('add_error'), err instanceof Error ? err.message : undefined);
}
};
const handleRemove = async (name: string) => {
const handleRemove = async (id: string) => {
try {
await removeAppPassword(name);
toast.success(t('app_passwords.removed'));
await onRemove(id);
toast.success(tk('removed'));
} catch (err) {
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined);
}
};
const handleCopySecret = () => {
if (!createdSecret) return;
navigator.clipboard.writeText(createdSecret).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (isLoadingAuth) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
@@ -312,53 +462,61 @@ function AppPasswordsSection() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" />
{t('app_passwords.add')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
<p className="text-xs text-muted-foreground">{tk('description')}</p>
{createdSecret && (
<div className="p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{tk('copy_now_warning')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono break-all">
{createdSecret}
</code>
<Button variant="outline" size="sm" onClick={handleCopySecret}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
{t('app_passwords.done')}
</Button>
</div>
)}
{showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
<label className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('app_passwords.name_placeholder')}
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder={tk('name_placeholder')}
required
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('app_passwords.password_placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
<textarea
value={allowedIpsRaw}
onChange={(e) => setAllowedIpsRaw(e.target.value)}
placeholder={t('app_passwords.allowed_ips_placeholder')}
rows={2}
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
@@ -369,47 +527,48 @@ function AppPasswordsSection() {
</form>
)}
{appPasswords.length > 0 ? (
{entries.length > 0 ? (
<div className="space-y-1">
{appPasswords.map((name) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
{entries.map((entry) => (
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
)}
</div>
);
}
function AppPasswordsSection() {
const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
return (
<CredentialSection
icon={Smartphone}
i18nNamespace="app_passwords"
entries={appPasswords}
onCreate={createAppPassword}
onRemove={removeAppPassword}
/>
);
}
function ApiKeysSection() {
const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
return (
<CredentialSection
icon={Terminal}
i18nNamespace="api_keys"
entries={apiKeys}
onCreate={createApiKey}
onRemove={removeApiKey}
/>
);
}
function EncryptionSection() {
const t = useTranslations('settings.security');
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
const handleToggle = async (enabled: boolean) => {
try {
if (enabled) {
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
toast.success(t('encryption.enabled'));
} else {
await updateEncryption({ type: 'disabled' });
toast.success(t('encryption.disabled_success'));
}
} catch (err) {
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
}
};
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
if (isLoadingCrypto) {
return (
@@ -419,24 +578,12 @@ function EncryptionSection() {
);
}
const isEnabled = encryptionType !== 'disabled';
const isEnabled = encryptionType !== 'Disabled';
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
</span>
</div>
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
</span>
</SettingItem>
);
}
@@ -446,7 +593,7 @@ function EmailClientSection() {
const { client } = useAuthStore();
const [copied, setCopied] = useState(false);
const jmapUsername = client?.getUsername() || '';
const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
const handleCopy = () => {
navigator.clipboard.writeText(jmapUsername).then(() => {
@@ -551,6 +698,9 @@ export function AccountSecuritySettings() {
<AppPasswordsSection />
<div className="border-t border-border" />
<ApiKeysSection />
{isOAuth && (
<>
<div className="border-t border-border" />
+7 -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, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup
value={theme}
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Language */}
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
{/* Font Size */}
{!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Density */}
{!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
{/* Toolbar Labels */}
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch
@@ -194,7 +126,10 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Restart Tour */}
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button
variant="outline"
+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,32 +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';
@@ -162,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;
@@ -172,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
);
@@ -189,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
@@ -214,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
@@ -230,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">
@@ -252,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
@@ -286,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}
@@ -294,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')}
@@ -321,7 +185,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Disable Thread Grouping */}
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
<ToggleSwitch
checked={disableThreading}
@@ -329,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}
@@ -337,71 +199,13 @@ export function EmailSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
checked={hideInlineImageAttachments}
onChange={(checked) => updateSetting('hideInlineImageAttachments', 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>
)}
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
<div>
@@ -434,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">
@@ -456,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>
@@ -504,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
@@ -519,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>
);
}
-14
View File
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useFilterStore } from '@/stores/filter-store';
import { useAuthStore } from '@/stores/auth-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -104,19 +103,6 @@ export function VacationSettings() {
textBody: localTextBody,
});
// Re-save the filter script to preserve metadata and include vacation block.
// This prevents the server from injecting vacation Sieve code that destroys
// the metadata comment the visual filter builder relies on.
try {
await useFilterStore.getState().syncVacationToScript(client, {
isEnabled: localEnabled,
subject: localSubject,
textBody: localTextBody,
});
} catch {
// Non-critical: vacation was saved via JMAP, script sync is best-effort
}
toast.success(tNotifications('vacation_saved'));
} catch (error) {
console.error('Failed to save vacation response:', error);
+1
View File
@@ -72,6 +72,7 @@ export default [
"node_modules/**",
"repos/**",
"data/admin/plugins/**",
"public/**/*.js",
"*.config.js",
"*.config.mjs",
"e2e/**",
+10 -14
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, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
@@ -106,13 +107,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders)
const destinationId = mailbox.originalId || mailbox.id;
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Move in a single bulk JMAP request (store handles counter updates).
await moveEmailsToMailbox(client, emailIds, mailbox.id);
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
@@ -122,15 +118,15 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Refresh the current mailbox view
await fetchEmails(client, selectedMailbox);
// Call success callback if provided, otherwise use fallback
const mailboxPath = getMailboxPath(mailbox, mailboxes);
if (onSuccess) {
onSuccess(emailIds.length, mailbox.name);
onSuccess(emailIds.length, mailboxPath);
} else {
// Fallback for backward compatibility
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
toast.success("Email moved", `Moved to ${mailboxPath}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
}
}
@@ -148,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+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);
}
/**
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useRef } from "react";
export interface UseRefreshGestureOptions {
onRefresh: () => void | Promise<void>;
enabled?: boolean;
}
/**
* Capture browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) and run
* a JMAP-level refresh instead of reloading the full page.
*
* Pull-to-refresh is only active when the document is already scrolled to the
* top, so normal touch scrolling is unaffected.
*/
export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestureOptions) {
const onRefreshRef = useRef(onRefresh);
const runningRef = useRef(false);
useEffect(() => {
onRefreshRef.current = onRefresh;
}, [onRefresh]);
useEffect(() => {
if (!enabled) return;
const trigger = () => {
if (runningRef.current) return;
runningRef.current = true;
Promise.resolve(onRefreshRef.current()).finally(() => {
runningRef.current = false;
});
};
const handleKeyDown = (event: KeyboardEvent) => {
const isReloadKey =
event.key === "F5" ||
((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "r");
if (!isReloadKey) return;
event.preventDefault();
event.stopPropagation();
trigger();
};
let touchStartY = 0;
let tracking = false;
let triggered = false;
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
tracking = false;
return;
}
const atTop = window.scrollY <= 0 && document.documentElement.scrollTop <= 0;
if (!atTop) {
tracking = false;
return;
}
touchStartY = event.touches[0].clientY;
tracking = true;
triggered = false;
};
const handleTouchMove = (event: TouchEvent) => {
if (!tracking || triggered) return;
const dy = event.touches[0].clientY - touchStartY;
// Require a deliberate pull of ~80px from the very top of the page.
if (dy > 80) {
triggered = true;
tracking = false;
trigger();
}
};
const handleTouchEnd = () => {
tracking = false;
triggered = false;
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("touchstart", handleTouchStart, { passive: true });
window.addEventListener("touchmove", handleTouchMove, { passive: true });
window.addEventListener("touchend", handleTouchEnd, { passive: true });
window.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd);
window.removeEventListener("touchcancel", handleTouchEnd);
};
}, [enabled]);
}
+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 {
+52
View File
@@ -5,6 +5,7 @@ import {
sanitizeSignatureHtml,
parseHtmlSafely,
hasRichFormatting,
plainTextToSafeHtml,
EMAIL_SANITIZE_CONFIG,
} from '../email-sanitization';
@@ -252,4 +253,55 @@ describe('email-sanitization', () => {
expect(clean).toContain('data:image/gif');
});
});
describe('plainTextToSafeHtml', () => {
it('escapes HTML-special characters in surrounding text', () => {
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\'');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('&amp;');
expect(result).toContain('&quot;');
expect(result).toContain('&#39;');
});
it('linkifies http(s) URLs', () => {
const result = plainTextToSafeHtml('visit http://example.com/path now');
expect(result).toContain('<a href="http://example.com/path"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('prevents attribute breakout via quote in URL (CVE regression)', () => {
const payload = 'http://evil.tld/"onmouseover="alert(1)"x="';
const result = plainTextToSafeHtml(payload);
// The anchor tag must not contain any unescaped attribute beyond href/target/rel.
expect(result).not.toMatch(/<a [^>]*onmouseover/i);
expect(result).not.toMatch(/<a [^>]*style=/i);
// Quotes from the payload must be entity-encoded wherever they land.
expect(result).toContain('&quot;');
});
it('prevents attribute breakout via style injection', () => {
const payload = 'http://evil.tld/"style="background:red"x="';
const result = plainTextToSafeHtml(payload);
expect(result).not.toMatch(/href="[^"]*"[^>]*style=/);
});
it('terminates URL at quote, keeping rest as escaped text', () => {
const result = plainTextToSafeHtml('http://evil.tld/"injected');
expect(result).toContain('<a href="http://evil.tld/"');
expect(result).toContain('&quot;injected');
});
it('applies linkClass when provided and escapes it', () => {
const result = plainTextToSafeHtml('http://x.com', 'text-primary hover:underline');
expect(result).toContain('class="text-primary hover:underline"');
});
it('does not linkify non-http schemes', () => {
const result = plainTextToSafeHtml('try javascript:alert(1) or file:///etc/passwd');
expect(result).not.toContain('<a ');
expect(result).toContain('javascript:alert(1)');
});
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/lib/auth/active-account-slot', () => ({
getActiveAccountSlotHeaders: vi.fn(() => ({ 'X-JMAP-Cookie-Slot': '0' })),
}));
import { stalwartJmap, requireResult, STALWART_JMAP_USING } from '@/lib/stalwart/jmap-passthrough';
import { apiFetch } from '@/lib/browser-navigation';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('stalwartJmap', () => {
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('POSTs to /api/account/stalwart/jmap with the standard using array', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
await stalwartJmap([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
expect(mockedFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockedFetch.mock.calls[0];
expect(url).toBe('/api/account/stalwart/jmap');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string);
expect(body.using).toEqual(STALWART_JMAP_USING);
expect(body.methodCalls).toEqual([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
});
it('forwards the active account slot header', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
await stalwartJmap([['x:Account/get', {}, '0']]);
const init = mockedFetch.mock.calls[0][1];
expect(init.headers['X-JMAP-Cookie-Slot']).toBe('0');
expect(init.headers['Content-Type']).toBe('application/json');
});
it('returns methodResponses on success', async () => {
const responses = [['x:AccountPassword/get', { list: [{ id: 'singleton' }] }, '0']];
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: responses }));
const result = await stalwartJmap([['x:AccountPassword/get', { accountId: 'a', ids: ['singleton'] }, '0']]);
expect(result).toEqual(responses);
});
it('throws with status and message when the passthrough returns non-OK', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'Not authenticated' }));
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
status: 401,
message: 'Not authenticated',
});
});
it('throws with HTTP fallback message when error body is unparseable', async () => {
mockedFetch.mockResolvedValueOnce(new Response('oh no', { status: 500 }));
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
status: 500,
message: 'HTTP 500',
});
});
it('throws when first method response is a JMAP-level error', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
methodResponses: [['error', { type: 'forbidden', description: 'Current secret must be provided' }, '0']],
}));
await expect(stalwartJmap([['x:AccountPassword/set', {}, '0']])).rejects.toMatchObject({
status: 200,
message: 'Current secret must be provided',
methodError: { type: 'forbidden', description: 'Current secret must be provided' },
});
});
it('falls back to error type when description is absent', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
methodResponses: [['error', { type: 'unknownMethod' }, '0']],
}));
await expect(stalwartJmap([['x:Nope/get', {}, '0']])).rejects.toMatchObject({
methodError: { type: 'unknownMethod' },
message: 'unknownMethod',
});
});
});
describe('requireResult', () => {
it('returns the arguments of the matching method', () => {
const responses: Array<[string, Record<string, unknown>, string]> = [
['x:Account/get', { list: [{ id: 'a' }] }, '0'],
['x:AppPassword/query', { ids: ['p1'] }, '1'],
];
const result = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
expect(result.ids).toEqual(['p1']);
});
it('throws when the expected method is missing', () => {
const responses: Array<[string, Record<string, unknown>, string]> = [
['x:Account/get', {}, '0'],
];
expect(() => requireResult(responses, 'x:AppPassword/query')).toThrow(/x:AppPassword\/query/);
});
});
-246
View File
@@ -1,246 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StalwartClient } from '../stalwart/client';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('StalwartClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: StalwartClient;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
client = new StalwartClient('https://mail.example.com/', 'Basic dXNlcjpwYXNz');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('constructor', () => {
it('strips trailing slash from server URL', () => {
const c = new StalwartClient('https://mail.example.com/', 'Basic abc');
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }));
c.getAuthInfo();
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.anything()
);
});
});
describe('probe', () => {
it('returns true when server responds with data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false } }));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns true when server returns 401 (API exists but needs auth)', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns false when server returns 404', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false when response has no data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { something: 'else' }));
const result = await client.probe();
expect(result).toBe(false);
});
});
describe('getAuthInfo', () => {
it('returns auth info on success', async () => {
const authInfo = { otpEnabled: true, isAdminApp: false, appPasswords: ['app1'] };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: authInfo }));
const result = await client.getAuthInfo();
expect(result).toEqual(authInfo);
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Basic dXNlcjpwYXNz',
}),
})
);
});
it('throws on non-ok response', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { detail: 'Forbidden' }));
await expect(client.getAuthInfo()).rejects.toThrow('Forbidden');
});
it('throws with HTTP status when error body is unparseable', async () => {
fetchSpy.mockResolvedValueOnce(new Response('not json', { status: 500 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 500');
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth action and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC123';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
const result = await client.enableTotp();
expect(result).toBe(totpUrl);
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'enableOtpAuth' }]);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth action', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.disableTotp();
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'disableOtpAuth' }]);
});
});
describe('addAppPassword', () => {
it('sends addAppPassword action with name and password', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.addAppPassword('Thunderbird', 'secret123');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret123' }]);
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword action with name', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.removeAppPassword('Thunderbird');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('getCryptoInfo', () => {
it('returns crypto info on success', async () => {
const cryptoInfo = { type: 'pgp' as const };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: cryptoInfo }));
const result = await client.getCryptoInfo();
expect(result).toEqual(cryptoInfo);
});
});
describe('updateCrypto', () => {
it('sends crypto settings', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateCrypto({ type: 'pgp' });
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual({ type: 'pgp' });
});
});
describe('getPrincipal', () => {
it('returns principal data on success', async () => {
const principal = {
id: 1, type: 'individual', name: 'testuser',
description: 'Test User', emails: ['test@example.com'],
secrets: [], quota: 1000000, roles: ['user'], lists: [],
};
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: principal }));
const result = await client.getPrincipal('testuser');
expect(result).toEqual(principal);
});
it('encodes special characters in username', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await client.getPrincipal('user@example.com');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/principal/user%40example.com',
expect.anything()
);
});
});
describe('updatePrincipal', () => {
it('sends PATCH with action array', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updatePrincipal('testuser', [
{ action: 'set', field: 'description', value: 'New Name' },
]);
const call = fetchSpy.mock.calls[0];
expect(call[0]).toBe('https://mail.example.com/api/principal/testuser');
expect(call[1]?.method).toBe('PATCH');
const body = JSON.parse(call[1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
});
describe('changePassword', () => {
it('sends set secrets action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.changePassword('testuser', 'newPassword123');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'secrets', value: 'newPassword123' }]);
});
});
describe('updateDisplayName', () => {
it('sends set description action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateDisplayName('testuser', 'John Doe');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'John Doe' }]);
});
});
describe('request error handling', () => {
it('parses error.detail from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { detail: 'Invalid request format' }));
await expect(client.getAuthInfo()).rejects.toThrow('Invalid request format');
});
it('parses error.details from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { details: 'Bad stuff' }));
await expect(client.getAuthInfo()).rejects.toThrow('Bad stuff');
});
it('parses error.error from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'Something wrong' }));
await expect(client.getAuthInfo()).rejects.toThrow('Something wrong');
});
it('falls back to HTTP status code on non-JSON error', async () => {
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 502');
});
});
});
-1
View File
@@ -110,7 +110,6 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
stalwartApiUrl: { envVar: 'STALWART_API_URL', type: 'url', defaultValue: '' },
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
+187
View File
@@ -0,0 +1,187 @@
import type { CalendarEvent } from "@/lib/jmap/types";
const MAX_LINE_OCTETS = 74;
function foldLine(line: string): string {
if (line.length <= MAX_LINE_OCTETS) return line;
const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)];
let pos = MAX_LINE_OCTETS;
while (pos < line.length) {
chunks.push(" " + line.slice(pos, pos + MAX_LINE_OCTETS - 1));
pos += MAX_LINE_OCTETS - 1;
}
return chunks.join("\r\n");
}
// RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values.
function escapeText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n|\r|\n/g, "\\n");
}
function stripDateSeparators(value: string): string {
return value.replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function dateOnly(value: string): string {
return value.replace(/-/g, "").substring(0, 8);
}
function formatNow(): string {
return stripDateSeparators(new Date().toISOString().replace(/\.\d{3}/, ""));
}
function pushDateProp(
lines: string[],
prop: "DTSTART" | "DTEND",
value: string,
showWithoutTime: boolean,
tz?: string | null,
): void {
if (showWithoutTime) {
lines.push(`${prop};VALUE=DATE:${dateOnly(value)}`);
return;
}
if (value.endsWith("Z")) {
lines.push(`${prop}:${stripDateSeparators(value)}`);
return;
}
const basic = stripDateSeparators(value);
if (tz) {
lines.push(`${prop};TZID=${tz}:${basic}`);
} else {
lines.push(`${prop}:${basic}`);
}
}
function pushFrequencyRule(lines: string[], event: CalendarEvent): void {
const rule = event.recurrenceRules?.[0];
if (!rule) return;
const parts: string[] = [`FREQ=${rule.frequency.toUpperCase()}`];
if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`);
if (rule.count != null) parts.push(`COUNT=${rule.count}`);
if (rule.until) parts.push(`UNTIL=${stripDateSeparators(rule.until)}`);
if (rule.byDay?.length) {
const days = rule.byDay
.map((d) => `${d.nthOfPeriod ?? ""}${d.day.toUpperCase()}`)
.join(",");
parts.push(`BYDAY=${days}`);
}
if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`);
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`);
lines.push(`RRULE:${parts.join(";")}`);
}
function pushAlerts(lines: string[], event: CalendarEvent): void {
if (!event.alerts) return;
for (const alert of Object.values(event.alerts)) {
const trigger = alert.trigger;
if (!trigger) continue;
lines.push("BEGIN:VALARM");
lines.push(`ACTION:${(alert.action || "display").toUpperCase()}`);
if (trigger["@type"] === "OffsetTrigger") {
const related = trigger.relativeTo === "end" ? ";RELATED=END" : "";
lines.push(`TRIGGER${related}:${trigger.offset}`);
} else if (trigger["@type"] === "AbsoluteTrigger") {
lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`);
}
lines.push(`DESCRIPTION:${escapeText(event.title || "Reminder")}`);
lines.push("END:VALARM");
}
}
export function eventToICS(event: CalendarEvent): string {
const now = formatNow();
const lines: string[] = [
"BEGIN:VCALENDAR",
"PRODID:-//JMAP-Webmail//EN",
"VERSION:2.0",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${event.uid}`,
`DTSTAMP:${now}`,
];
if (event.created) lines.push(`CREATED:${stripDateSeparators(event.created)}`);
if (event.updated) lines.push(`LAST-MODIFIED:${stripDateSeparators(event.updated)}`);
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
if (event.start) {
pushDateProp(lines, "DTSTART", event.start, event.showWithoutTime, event.timeZone);
}
if (event.utcEnd) {
pushDateProp(lines, "DTEND", event.utcEnd, event.showWithoutTime, event.timeZone);
} else if (event.duration) {
lines.push(`DURATION:${event.duration}`);
}
if (event.title) lines.push(`SUMMARY:${escapeText(event.title)}`);
if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`);
if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`);
if (event.privacy) lines.push(`CLASS:${event.privacy.toUpperCase()}`);
if (event.freeBusyStatus) {
lines.push(`TRANSP:${event.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`);
}
if (event.locations) {
const first = Object.values(event.locations)[0];
if (first?.name) lines.push(`LOCATION:${escapeText(first.name)}`);
}
if (event.virtualLocations) {
for (const loc of Object.values(event.virtualLocations)) {
if (loc.uri) lines.push(`URL:${loc.uri}`);
}
}
if (event.participants) {
const organizer = Object.values(event.participants).find((p) => p.roles?.owner);
if (organizer) {
const email = organizer.email || organizer.sendTo?.imip?.replace("mailto:", "");
if (email) {
const cn = organizer.name ? `;CN=${escapeText(organizer.name)}` : "";
lines.push(`ORGANIZER${cn}:mailto:${email}`);
}
}
for (const p of Object.values(event.participants)) {
if (p.roles?.owner) continue;
const email = p.email || p.sendTo?.imip?.replace("mailto:", "");
if (!email) continue;
const cn = p.name ? `;CN=${escapeText(p.name)}` : "";
const partstat = p.participationStatus
? `;PARTSTAT=${p.participationStatus.toUpperCase()}`
: ";PARTSTAT=NEEDS-ACTION";
const rsvp = p.expectReply ? ";RSVP=TRUE" : "";
lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`);
}
}
pushFrequencyRule(lines, event);
pushAlerts(lines, event);
lines.push("END:VEVENT");
lines.push("END:VCALENDAR");
return lines.map(foldLine).join("\r\n") + "\r\n";
}
function sanitizeFilename(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, "_").trim();
return cleaned || "event";
}
export function downloadEventICS(event: CalendarEvent): void {
const ics = eventToICS(event);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(event.title || "event")}.ics`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
+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}`;
}
+35 -2
View File
@@ -49,6 +49,10 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
hasAccountCapability(_capability: string, _accountId?: string): boolean {
return false;
}
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
@@ -265,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId);
return;
}
for (const { id, receivedAt } of emails) {
const email = this.data.emails.find(e => e.id === id);
if (!email) continue;
const d = new Date(receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId);
if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId);
let destId = yearBox.id;
if (mode === 'month') {
let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id);
if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id);
destId = monthBox.id;
}
email.mailboxIds = { [destId]: true };
}
this.recalcMailboxCounts();
}
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [toMailboxId]: true };
@@ -317,7 +350,7 @@ export class DemoJMAPClient implements IJMAPClient {
_identityId?: string,
_fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
_fromName?: string,
): Promise<string> {
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
@@ -365,7 +398,7 @@ export class DemoJMAPClient implements IJMAPClient {
draftId?: string,
_fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
): Promise<void> {
// Remove draft if updating
if (draftId) {
+54
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
@@ -80,6 +100,40 @@ export function hasRichFormatting(html: string): boolean {
);
}
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
/**
* Render a plain-text email body as HTML, HTML-escaping all content and
* linkifying http(s) URLs. URLs terminate at whitespace or any character that
* would break an attribute (`"`, `'`, `<`, `>`), so attribute-escaping is
* enforced even if escaping has bugs.
*/
export function plainTextToSafeHtml(text: string, linkClass = ''): string {
const urlRegex = /(https?:\/\/[^\s<>"']+)/g;
const classAttr = linkClass ? ` class="${escapeHtml(linkClass)}"` : '';
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = urlRegex.exec(text)) !== null) {
result += escapeHtml(text.slice(lastIndex, match.index));
const url = escapeHtml(match[0]);
result += `<a href="${url}" target="_blank" rel="noopener noreferrer"${classAttr}>${url}</a>`;
lastIndex = match.index + match[0].length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
/**
* Collapse empty containers left behind when external images are blocked.
* Walks up from each blocked img to find the nearest table cell or wrapper div
+10 -2
View File
@@ -27,6 +27,7 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
hasAccountCapability(capability: string, accountId?: string): boolean;
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
@@ -84,6 +85,13 @@ export interface IJMAPClient {
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
): Promise<void>;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>;
@@ -103,7 +111,7 @@ export interface IJMAPClient {
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string,
): Promise<string>;
@@ -118,7 +126,7 @@ export interface IJMAPClient {
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
): Promise<void>;
sendImipReply(opts: {
+171 -12
View File
@@ -446,12 +446,39 @@ export class JMAPClient implements IJMAPClient {
return response;
}
private async refreshSession(): Promise<void> {
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
const response = await fetch(sessionUrl, {
/**
* Fetch the JMAP session, transparently handling servers that redirect
* /.well-known/jmap to a canonical session URL (e.g. Stalwart /jmap/session).
*
* Safari strips the Authorization header on cross-origin redirects even when
* the redirect destination is same-origin as the original request, and some
* reverse-auth proxies (e.g. Pangolin) admit the redirected request via a
* cookie without the Authorization header. Stalwart responds to an
* unauthenticated /jmap/session with 200 + empty accounts rather than 401,
* so the drop is silent and downstream parsing fails with "No mail account
* found in session".
*
* Detect that case (response.redirected, empty accounts, empty username)
* and retry directly against the final URL so we can re-send Authorization.
*/
private async fetchSessionResponse(): Promise<Response> {
const discoveryUrl = `${this.serverUrl}/.well-known/jmap`;
const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' });
if (!response.ok || !response.redirected) return response;
const peek = await response.clone().json().catch(() => null);
const hasAccounts = peek && Object.keys(peek.accounts || {}).length > 0;
const hasUsername = typeof peek?.username === 'string' && peek.username.length > 0;
if (hasAccounts || hasUsername) return response;
return fetch(response.url, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
}
private async refreshSession(): Promise<void> {
const response = await this.fetchSessionResponse();
if (!response.ok) {
throw new Error(`Session refresh failed: ${response.status}`);
@@ -470,9 +497,7 @@ export class JMAPClient implements IJMAPClient {
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
try {
const sessionResponse = await this.authenticatedFetch(sessionUrl, {
method: 'GET',
});
const sessionResponse = await this.fetchSessionResponse();
if (!sessionResponse.ok) {
if (sessionResponse.status === 401) {
@@ -1182,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([
@@ -1291,7 +1423,13 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.[createId]) {
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
const err = result.notCreated[createId];
const details = [err.type || 'unknown error'];
if (Array.isArray(err.properties) && err.properties.length > 0) {
details.push(`properties=[${err.properties.join(', ')}]`);
}
if (err.description) details.push(err.description);
throw new Error(`Failed to create mailbox: ${details.join(' ')}`);
}
const created = result?.created?.[createId];
@@ -1701,7 +1839,7 @@ export class JMAPClient implements IJMAPClient {
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string
): Promise<string> {
const mailboxes = await this.getMailboxes();
@@ -1722,7 +1860,7 @@ export class JMAPClient implements IJMAPClient {
mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>;
textBody: { partId: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
}
const emailData: EmailDraft = {
@@ -1742,7 +1880,8 @@ export class JMAPClient implements IJMAPClient {
blobId: att.blobId,
type: att.type,
name: att.name,
disposition: "attachment",
disposition: att.disposition ?? "attachment",
...(att.cid ? { cid: att.cid } : {}),
}));
}
@@ -1795,7 +1934,7 @@ export class JMAPClient implements IJMAPClient {
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>
): Promise<void> {
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
@@ -1865,7 +2004,8 @@ export class JMAPClient implements IJMAPClient {
blobId: att.blobId,
type: att.type,
name: att.name,
disposition: "attachment",
disposition: att.disposition ?? "attachment",
...(att.cid ? { cid: att.cid } : {}),
}));
}
@@ -1947,6 +2087,10 @@ export class JMAPClient implements IJMAPClient {
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
identityId?: string;
}): Promise<void> {
if (!opts.uid) {
debug.warn('calendar', '[iMIP] sendImipReply aborted: missing UID');
return;
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) {
@@ -2124,6 +2268,10 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipInvitation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (!event.uid) {
debug.warn('calendar', '[iMIP] sendImipInvitation aborted: event has no UID', { eventId: event.id });
return;
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2290,6 +2438,10 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (!event.uid) {
debug.warn('calendar', '[iMIP] sendImipCancellation aborted: event has no UID', { eventId: event.id });
return;
}
if (event.status && event.status !== 'cancelled') {
debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status);
}
@@ -2519,6 +2671,13 @@ export class JMAPClient implements IJMAPClient {
return capability in this.capabilities;
}
/** Check whether a capability is present on the primary account. */
hasAccountCapability(capability: string, accountId?: string): boolean {
const id = accountId || this.accountId;
const caps = this.session?.accounts?.[id]?.accountCapabilities;
return !!caps && capability in caps;
}
getMaxSizeUpload(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
return coreCapability?.maxSizeUpload || 0;
@@ -264,4 +264,94 @@ describe('external rule preservation (issue #201)', () => {
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
});
});
describe('Nextcloud Mail marker regions', () => {
const NEXTCLOUD_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const nextcloudScript = [
NEXTCLOUD_MARKER,
'require ["imap4flags", "fileinto"];',
NEXTCLOUD_MARKER,
'',
NEXTCLOUD_MARKER,
'# FILTER: [{"name":"Wetterwarnungen"}]',
'if header :contains "From" "weather@x.com" {',
' fileinto "Weather";',
'}',
'',
'# PayPal',
'if header :contains "From" "paypal.de" {',
' addflag "$paypal";',
'}',
NEXTCLOUD_MARKER,
'',
].join('\n');
it('collapses a Nextcloud marker-pair region into one opaque rule labeled "Nextcloud"', () => {
const result = parseScript(nextcloudScript);
const nextcloud = result.rules.filter(r => r.originLabel === 'Nextcloud');
expect(nextcloud).toHaveLength(1);
expect(nextcloud[0].origin).toBe('opaque');
// Every marker that wrapped actual rule content survives in the rawBlock.
const markerCount = (nextcloud[0].rawBlock || '').split(NEXTCLOUD_MARKER).length - 1;
expect(markerCount).toBe(2);
// Inner if-blocks are not exposed as separate external rules.
expect(result.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('merges require tokens from inside Nextcloud regions into externalRequires', () => {
const result = parseScript(nextcloudScript);
expect(result.externalRequires).toEqual(expect.arrayContaining(['imap4flags', 'fileinto']));
});
it('drops require-only Nextcloud regions (no separate opaque rule for them)', () => {
const script = [
NEXTCLOUD_MARKER,
'require ["fileinto"];',
NEXTCLOUD_MARKER,
'',
].join('\n');
const result = parseScript(script);
expect(result.rules).toHaveLength(0);
expect(result.externalRequires).toContain('fileinto');
});
it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
const parsed = parseScript(initial);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// Both wrapping markers remain, in the right positions.
const firstMarker = regenerated.indexOf(NEXTCLOUD_MARKER);
const lastMarker = regenerated.lastIndexOf(NEXTCLOUD_MARKER);
expect(firstMarker).toBeGreaterThanOrEqual(0);
expect(lastMarker).toBeGreaterThan(firstMarker);
expect(regenerated).toContain('# FILTER: [{"name":"Wetterwarnungen"}]');
expect(regenerated).toContain('# PayPal');
const reparsed = parseScript(regenerated);
expect(reparsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(1);
expect(reparsed.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('does not emit duplicate "External rules" headers across repeated saves', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
let script = initial;
for (let i = 0; i < 3; i++) {
const parsed = parseScript(script);
script = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
}
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
expect(headerCount).toBe(1);
});
});
});
+133 -12
View File
@@ -21,6 +21,11 @@ const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
const NEXTCLOUD_BLOCK_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const BULWARK_EXTERNAL_HEADER_RE =
/^[ \t]*#[ \t]*---[ \t]*External rules \(managed outside Bulwark\)[ \t]*---[ \t]*\r?\n/m;
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
from: 'from',
to: 'to',
@@ -496,6 +501,103 @@ function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): Filte
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Nextcloud Mail wraps its managed filter region with a pair of
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
* emits two such regions - one enclosing its own `require [...]` line and
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
* JSON comments. Parsing the interior blocks individually loses the outer
* markers (causing later rules to fall back to "External") and mis-attaches
* them to neighboring blocks on round-trip.
*
* Treat each marker-pair as one opaque external rule so the whole region
* round-trips verbatim, merge any require tokens into the top-level list,
* and drop regions that carry nothing but a require (their extensions are
* already represented in the merged require line).
*/
function extractNextcloudRegions(content: string): {
cleaned: string;
rules: FilterRule[];
requires: string[];
} {
const marker = NEXTCLOUD_BLOCK_MARKER;
const positions: number[] = [];
let searchFrom = 0;
while (true) {
const idx = content.indexOf(marker, searchFrom);
if (idx === -1) break;
const atLineStart = idx === 0 || content[idx - 1] === '\n';
if (atLineStart) positions.push(idx);
searchFrom = idx + marker.length;
}
if (positions.length < 2) {
return { cleaned: content, rules: [], requires: [] };
}
const rules: FilterRule[] = [];
const requires: string[] = [];
const markerRe = new RegExp(escapeRegex(marker), 'g');
let cleaned = '';
let cursor = 0;
for (let i = 0; i + 1 < positions.length; i += 2) {
const start = positions[i];
const closeStart = positions[i + 1];
const lineEnd = content.indexOf('\n', closeStart + marker.length);
const end = lineEnd === -1 ? content.length : lineEnd + 1;
cleaned += content.slice(cursor, start);
const raw = content.slice(start, end);
for (const m of raw.matchAll(/require\s+\[([\s\S]*?)\]\s*;/g)) {
for (const tok of m[1].matchAll(/"([^"]+)"/g)) {
if (!requires.includes(tok[1])) requires.push(tok[1]);
}
}
const singleReq = /require\s+"([^"]+)"\s*;/.exec(raw);
if (singleReq && !requires.includes(singleReq[1])) requires.push(singleReq[1]);
const stripped = raw
.replace(markerRe, '')
.replace(/require\s+\[[\s\S]*?\]\s*;/g, '')
.replace(/require\s+"[^"]+"\s*;/g, '')
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
if (stripped.length > 0) {
rules.push({
id: `nextcloud-${rules.length}`,
name: 'Nextcloud Mail filters',
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel: 'Nextcloud',
rawBlock: raw,
});
}
cursor = end;
}
cleaned += content.slice(cursor);
return { cleaned, rules, requires };
}
function stripBulwarkExternalHeader(content: string): string {
return content.replace(BULWARK_EXTERNAL_HEADER_RE, '');
}
function parseExternalRules(
content: string,
idPrefix: string,
@@ -557,17 +659,25 @@ export function parseScript(content: string): ParseResult {
if (!isValidRule(rule)) return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules.
const afterMetadata = content.slice(endIdx + METADATA_END.length);
const external = parseExternalRules(afterMetadata, 'ext');
// Scan the portion AFTER the metadata block for external rules. A prior
// Bulwark save may have emitted its "External rules" header here; strip
// it so it does not get re-attached to the first external rule's rawBlock
// and written out twice on the next save.
const afterMetadata = stripBulwarkExternalHeader(
content.slice(endIdx + METADATA_END.length),
);
const nextcloud = extractNextcloudRegions(afterMetadata);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
// Parsed bulwark rules intentionally omit an explicit `origin` field so
// round-trip equality with metadata-only callers holds. Absence of origin
// is treated as 'bulwark' everywhere downstream.
const bulwarkRules: FilterRule[] = metadata.rules;
// Exclude requires and the vacation line that we emit ourselves from externalRequires.
const externalRequires = external.externalRequires;
const externalRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
@@ -584,7 +694,7 @@ export function parseScript(content: string): ParseResult {
});
return {
rules: [...bulwarkRules, ...filteredExternal],
rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal],
isOpaque: false,
vacation: metadata.vacation,
externalRequires,
@@ -595,18 +705,29 @@ export function parseScript(content: string): ParseResult {
const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
// Try to parse the whole script as external rules.
const external = parseExternalRules(content, 'ext');
// Extract Nextcloud-managed marker regions first so their interior is not
// parsed as a series of loose if-blocks (which would lose the outer markers
// and mis-label later blocks as generic "External").
const nextcloud = extractNextcloudRegions(content);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
if (!external.hasContent) {
const allRules = [...nextcloud.rules, ...external.rules];
const allRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
if (!external.hasContent && allRules.length === 0) {
// Entirely empty or whitespace/comments only - treat as empty, editable.
return { rules: [], isOpaque: false, externalRequires: [] };
// Preserve any require tokens lifted out of Nextcloud marker regions so
// they can be re-emitted in the top-level require line.
return { rules: [], isOpaque: false, externalRequires: allRequires };
}
// If at least one block parsed into a structured rule, expose them as external.
const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || external.rules.length > 0) {
return { rules: external.rules, isOpaque: false, externalRequires: external.externalRequires };
if (anyParsed || allRules.length > 0) {
return { rules: allRules, isOpaque: false, externalRequires: allRequires };
}
return OPAQUE;
-185
View File
@@ -1,185 +0,0 @@
/**
* Stalwart Management API Client
*
* Provides typed access to Stalwart's /api/ endpoints for user self-service:
* - Password change (PATCH /principal/{name})
* - Display name update (PATCH /principal/{name})
* - App passwords (POST /account/auth)
* - TOTP 2FA management (POST /account/auth)
* - Encryption-at-rest (GET/POST /account/crypto)
* - Account auth info (GET /account/auth)
*/
export interface StalwartAuthInfo {
otpEnabled: boolean;
isAdminApp: boolean;
appPasswords: string[];
}
export interface StalwartCryptoInfo {
type: 'disabled' | 'pgp' | 'smime';
}
export interface StalwartPrincipal {
id: number;
type: string;
name: string;
description: string;
emails: string | string[];
secrets: string | string[];
quota: number;
roles: string[];
lists: string[];
}
export interface PrincipalUpdateAction {
action: 'set' | 'addItem' | 'removeItem';
field: string;
value: string | number;
}
export interface StalwartApiError {
error: string;
details: string;
reason?: string | null;
}
export class StalwartClient {
private baseUrl: string;
private authHeader: string;
constructor(serverUrl: string, authHeader: string) {
this.baseUrl = serverUrl.replace(/\/$/, '') + '/api';
this.authHeader = authHeader;
}
// eslint-disable-next-line no-undef
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
let errorDetail = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body.detail) errorDetail = body.detail;
else if (body.details) errorDetail = body.details;
else if (body.error) errorDetail = body.error;
} catch { /* use status code */ }
throw new Error(errorDetail);
}
return response.json();
}
/** Probe whether this server exposes Stalwart's management API */
async probe(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/account/auth`, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
if (response.status === 401) return true; // API exists but needs auth
if (!response.ok) return false;
const data = await response.json();
return data.data !== undefined;
} catch {
return false;
}
}
/** GET /account/auth - Fetch 2FA and app password status */
async getAuthInfo(): Promise<StalwartAuthInfo> {
const result = await this.request<{ data: StalwartAuthInfo }>('/account/auth');
return result.data;
}
/** POST /account/auth - Update auth settings (TOTP, app passwords) */
async updateAuth(actions: Array<{ type: string; name?: string; password?: string; url?: string }>): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify(actions),
});
}
/** Enable TOTP - returns the TOTP URL for QR code generation */
async enableTotp(): Promise<string> {
const result = await this.request<{ data: string }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
return result.data;
}
/** Disable TOTP */
async disableTotp(): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
}
/** Add an app password */
async addAppPassword(name: string, password: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
}
/** Remove an app password */
async removeAppPassword(name: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
}
/** GET /account/crypto - Fetch encryption-at-rest settings */
async getCryptoInfo(): Promise<StalwartCryptoInfo> {
const result = await this.request<{ data: StalwartCryptoInfo }>('/account/crypto');
return result.data;
}
/** POST /account/crypto - Update encryption-at-rest settings */
async updateCrypto(settings: { type: string; algo?: string; certs?: string }): Promise<void> {
await this.request<{ data: unknown }>('/account/crypto', {
method: 'POST',
body: JSON.stringify(settings),
});
}
/** GET /principal/{name} - Fetch principal details */
async getPrincipal(name: string): Promise<StalwartPrincipal> {
const result = await this.request<{ data: StalwartPrincipal }>(`/principal/${encodeURIComponent(name)}`);
return result.data;
}
/** PATCH /principal/{name} - Update principal fields */
async updatePrincipal(name: string, actions: PrincipalUpdateAction[]): Promise<void> {
await this.request<{ data: unknown }>(`/principal/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(actions),
});
}
/** Change password via PATCH /principal/{name} */
async changePassword(name: string, newPassword: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'secrets', value: newPassword },
]);
}
/** Update display name via PATCH /principal/{name} */
async updateDisplayName(name: string, displayName: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'description', value: displayName },
]);
}
}
+2 -25
View File
@@ -4,9 +4,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
export interface StalwartCredentials {
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
apiUrl: string;
/** URL of the JMAP server (for JMAP operations like password verification) */
/** URL of the JMAP server (used for JMAP + management method calls) */
serverUrl: string;
authHeader: string;
username: string;
@@ -14,26 +12,6 @@ export interface StalwartCredentials {
slot: number;
}
/**
* Resolve the base URL for Stalwart management API requests.
*
* When the JMAP server sits behind a reverse proxy that only forwards
* JMAP paths, the `/api/account/*` and `/api/principal/*` management
* endpoints may not be exposed. In that case, operators can set
* `STALWART_API_URL` to point directly at the Stalwart HTTP listener
* (e.g. `https://admin.example.com`).
*/
function getStalwartApiUrl(jmapServerUrl: string): string {
const url = process.env.STALWART_API_URL || jmapServerUrl;
return url.replace(/\/+$/, '');
}
/**
* Extract credentials from the incoming request.
*
* Credentials are read from a verified, httpOnly auth-context cookie that is
* populated after a successful JMAP login or token refresh.
*/
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
@@ -55,8 +33,7 @@ export async function getStalwartCredentials(request: NextRequest): Promise<Stal
if (!context) continue;
return {
apiUrl: getStalwartApiUrl(context.serverUrl),
serverUrl: context.serverUrl,
serverUrl: context.serverUrl.replace(/\/+$/, ''),
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
+66
View File
@@ -0,0 +1,66 @@
import { apiFetch } from '@/lib/browser-navigation';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
export type JmapMethodCall = [string, Record<string, unknown>, string];
export type JmapMethodResponse = [string, Record<string, unknown>, string];
export const STALWART_JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
export interface StalwartJmapError extends Error {
status: number;
methodError?: { type: string; description?: string };
}
function buildError(message: string, status: number, methodError?: StalwartJmapError['methodError']): StalwartJmapError {
const err = new Error(message) as StalwartJmapError;
err.status = status;
if (methodError) err.methodError = methodError;
return err;
}
/**
* Send a JMAP request to Stalwart via the server-side passthrough.
* The passthrough injects the stored basic-auth header so credentials
* stay in an httpOnly cookie.
*/
export async function stalwartJmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
const response = await apiFetch('/api/account/stalwart/jmap', {
method: 'POST',
headers: { ...getActiveAccountSlotHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ using: STALWART_JMAP_USING, methodCalls }),
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body?.error) message = body.error;
} catch { /* ignore */ }
throw buildError(message, response.status);
}
const data = await response.json();
const responses = (data.methodResponses ?? []) as JmapMethodResponse[];
const first = responses[0];
if (first && first[0] === 'error') {
const result = first[1] as { type?: string; description?: string };
throw buildError(result.description || result.type || 'JMAP error', 200, {
type: result.type || 'unknown',
description: result.description,
});
}
return responses;
}
export function requireResult<T = Record<string, unknown>>(
responses: JmapMethodResponse[],
expectedMethod: string,
): T {
const match = responses.find(r => r[0] === expectedMethod);
if (!match) {
throw buildError(`Expected method ${expectedMethod} in response`, 200);
}
return match[1] as T;
}
+19
View File
@@ -429,4 +429,23 @@ export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
traverse(nodes);
return result;
}
export function getMailboxPath(
mailbox: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
allMailboxes: Array<Pick<Mailbox, 'id' | 'name' | 'parentId'>>,
separator = ' ',
): string {
const byId = new Map(allMailboxes.map(m => [m.id, m]));
const names: string[] = [mailbox.name];
const visited = new Set<string>([mailbox.id]);
let parentId = mailbox.parentId;
while (parentId && !visited.has(parentId)) {
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) break;
names.unshift(parent.name);
parentId = parent.parentId;
}
return names.join(separator);
}
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Ordner",
"mail": "E-Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Geteilt"
},
"sidebar_apps": {
"modal_title": "Sidebar-Apps",
@@ -670,14 +671,24 @@
"files": "Dateien",
"contacts": "Kontakte",
"sidebar_apps": "Sidebar-Apps",
"notifications": "Benachrichtigungen"
"notifications": "Benachrichtigungen",
"layout": "Layout",
"reading": "Lesen",
"composing": "Verfassen",
"content_senders": "Inhalte & Absender",
"about_data": "Über & Daten",
"debug": "Debug"
},
"tab_groups": {
"general": "Allgemein",
"account": "Konto & Identität",
"organization": "E-Mail-Organisation",
"apps": "Apps",
"system": "System"
"system": "System",
"appearance": "Darstellung",
"mail": "E-Mail",
"privacy": "Datenschutz & Sicherheit",
"advanced": "Erweitert"
},
"appearance": {
"title": "Darstellung",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Gemeinsames Postfach",
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
},
"colorful_sidebar_icons": {
"label": "Farbige Seitenleistensymbole",
"description": "Ordner- und Tag-Symbole nach Typ einfärben (blauer Posteingang, roter Spam, grüner Gesendet usw.). Für eine monochrome Seitenleiste deaktivieren."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Schlüsselwort hinzufügen...",
"add": "Hinzufügen",
"remove": "Entfernen"
},
"hide_inline_image_attachments": {
"label": "Eingebettete Bilder in Anhangsliste ausblenden",
"description": "Im Nachrichtentext eingebettete Bilder werden nicht als separate Anhänge aufgeführt"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert",
"enable_error": "2FA konnte nicht aktiviert werden",
"disable_error": "2FA konnte nicht deaktiviert werden",
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):"
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):",
"verification_code": "Verifizierungscode",
"confirm": "Bestätigen",
"disable": "Deaktivieren",
"disable_confirm_prompt": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.",
"password_required": "Passwort ist erforderlich",
"code_required": "Verifizierungscode ist erforderlich",
"code_invalid": "Ungültiger Verifizierungscode. Überprüfen Sie Ihre Authenticator-App und versuchen Sie es erneut."
},
"app_passwords": {
"title": "App-Passwörter",
@@ -1088,7 +1114,25 @@
"removed": "App-Passwort entfernt",
"add_error": "App-Passwort konnte nicht erstellt werden",
"remove_error": "App-Passwort konnte nicht entfernt werden",
"none": "Keine App-Passwörter konfiguriert"
"none": "Keine App-Passwörter konfiguriert",
"done": "Fertig",
"expires_label": "Läuft ab (optional)",
"copy_now_warning": "Kopieren Sie dieses Passwort jetzt - es wird nicht erneut angezeigt.",
"allowed_ips_label": "Erlaubte IPs (optional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Komma- oder leerzeichengetrennt. Leer lassen, um jede IP zuzulassen."
},
"api_keys": {
"title": "API-Schlüssel",
"description": "Erstellen Sie API-Schlüssel für Skripte und Integrationen, die direkt mit dem Server kommunizieren",
"name_label": "Schlüsselname",
"name_placeholder": "z.B. Backup-Skript, CI-Runner",
"copy_now_warning": "Kopieren Sie diesen API-Schlüssel jetzt - er wird nicht erneut angezeigt.",
"added": "API-Schlüssel erstellt",
"removed": "API-Schlüssel entfernt",
"add_error": "API-Schlüssel konnte nicht erstellt werden",
"remove_error": "API-Schlüssel konnte nicht entfernt werden",
"none": "Keine API-Schlüssel konfiguriert"
},
"encryption": {
"section_title": "Verschlüsselung im Ruhezustand",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}",
"actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}"
}
},
"origin_external": "Extern",
"managed_by_tooltip": "Verwaltet von {source}. Bearbeiten Sie sie in dieser App oder verwenden Sie den rohen Sieve-Editor."
},
"templates": {
"title": "E-Mail-Vorlagen",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_search": "Keine Kontakte gefunden",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
"clear_search": "Suche löschen",
"import_vcard": "vCard importieren",
"delete_confirm_title": "Kontakt löschen",
@@ -1778,7 +1826,33 @@
"import_to_smime": "In S/MIME importieren",
"cert_already_imported": "Zertifikat bereits importiert",
"cert_imported": "Zertifikat importiert",
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
"cert_import_failed": "Import des Zertifikats fehlgeschlagen",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Weitere Aktionen",
"age_years": "{count, plural, one {1 Jahr alt} other {# Jahre alt}}",
"years_since": "{count, plural, one {1 Jahr} other {# Jahre}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Neuer Kontakt",
@@ -1874,7 +1948,13 @@
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden",
"delete": "Löschen"
"delete": "Löschen",
"upload_photo": "Foto hochladen",
"remove_photo": "Foto entfernen",
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
"photo_invalid": "Ungültige Bilddatei",
"change_photo": "Ändern"
},
"groups": {
"create": "Neue Gruppe",
@@ -1934,6 +2014,37 @@
"error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden"
},
"context_menu": {
"open": "Öffnen",
"edit": "Bearbeiten",
"send_email": "E-Mail senden",
"add_to_group": "Zur Gruppe hinzufügen",
"export_vcard": "Als vCard exportieren",
"delete": "Löschen",
"call": "Anrufen",
"duplicate": "Duplizieren",
"print": "Drucken"
},
"filters": {
"toggle": "Filter",
"select": "Auswählen",
"clear": "Zurücksetzen",
"close": "Schließen",
"title": "Erweiterte Filter",
"organization": "Firma",
"organization_placeholder": "z. B. Acme GmbH",
"job_title": "Berufsbezeichnung",
"job_title_placeholder": "z. B. Designer",
"location": "Ort",
"location_placeholder": "Stadt oder Land",
"email_domain": "E-Mail-Domain",
"email_domain_placeholder": "beispiel.de",
"birthday_month": "Geburtstag im",
"any_month": "Beliebiger Monat",
"has_email": "Mit E-Mail",
"has_phone": "Mit Telefon",
"has_photo": "Mit Foto"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Termingröße ändern",
"duplicate": "Duplizieren",
"today_header": "Heute",
"tomorrow_header": "Morgen"
"tomorrow_header": "Morgen",
"export_ics": "Als .ics exportieren",
"copy_title": "Titel kopieren",
"copy_link": "Meeting-Link kopieren"
},
"detail": {
"add_note": "Notiz hinzufügen...",
@@ -2006,6 +2120,7 @@
"tentative": "Vorläufig",
"needs_action": "Antwort ausstehend",
"remove": "Entfernen",
"edit": "Bearbeiten",
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
"send_invitations": "Einladungen an Teilnehmer senden",
"status_summary": "{accepted} zugesagt, {pending} ausstehend",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
"event_duplicated": "Termin dupliziert",
"event_error": "Termin konnte nicht gespeichert werden",
"task_due": "Aufgabe fällig"
"task_due": "Aufgabe fällig",
"event_exported": "Termin exportiert",
"title_copied": "Titel kopiert",
"link_copied": "Link kopiert"
},
"status": {
"loading_calendars": "Kalender werden geladen...",
+123 -8
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",
@@ -966,6 +978,10 @@
"add_placeholder": "Add keyword...",
"add": "Add",
"remove": "Remove"
},
"hide_inline_image_attachments": {
"label": "Hide inline images from attachments",
"description": "Images embedded in the message body are not listed as separate attachments"
}
},
"composer": {
@@ -1076,7 +1092,14 @@
"disabled": "Two-factor authentication disabled",
"enable_error": "Failed to enable 2FA",
"disable_error": "Failed to disable 2FA",
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):"
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):",
"verification_code": "Verification code",
"confirm": "Confirm",
"disable": "Disable",
"disable_confirm_prompt": "Enter your password to disable two-factor authentication.",
"password_required": "Password is required",
"code_required": "Verification code is required",
"code_invalid": "Invalid verification code. Check your authenticator app and try again."
},
"app_passwords": {
"title": "App Passwords",
@@ -1084,17 +1107,35 @@
"add": "Add",
"create": "Create",
"cancel": "Cancel",
"done": "Done",
"generate": "Generate",
"name_label": "App Name",
"name_placeholder": "e.g. Thunderbird, iPhone Mail",
"expires_label": "Expires (optional)",
"allowed_ips_label": "Allowed IPs (optional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Comma- or space-separated. Leave empty to allow any IP.",
"password_label": "Password (leave empty to auto-generate)",
"password_placeholder": "Auto-generated if empty",
"copy_now_warning": "Copy this password now - it will not be shown again.",
"added": "App password created",
"removed": "App password removed",
"add_error": "Failed to create app password",
"remove_error": "Failed to remove app password",
"none": "No app passwords configured"
},
"api_keys": {
"title": "API Keys",
"description": "Create API keys for scripts and integrations that talk to the server directly",
"name_label": "Key Name",
"name_placeholder": "e.g. Backup script, CI runner",
"copy_now_warning": "Copy this API key now - it will not be shown again.",
"added": "API key created",
"removed": "API key removed",
"add_error": "Failed to create API key",
"remove_error": "Failed to remove API key",
"none": "No API keys configured"
},
"encryption": {
"section_title": "Encryption at Rest",
"label": "Email Encryption",
@@ -1281,6 +1322,8 @@
"contacts": {
"title": "Contacts",
"description": "Import and export your contacts",
"group_by_letter_label": "Group by first letter",
"group_by_letter_description": "Show alphabetical section headers in the contact list",
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
@@ -1708,6 +1751,8 @@
"empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term",
"empty_filtered": "No contacts match your filters",
"empty_filtered_hint": "Try adjusting or clearing filters",
"clear_search": "Clear search",
"import_vcard": "Import vCard",
"delete_confirm_title": "Delete contact",
@@ -1785,7 +1830,33 @@
"calendar": "Calendar",
"calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL"
"freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "More actions",
"age_years": "{count, plural, one {1 year old} other {# years old}}",
"years_since": "{count, plural, one {1 year} other {# years}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "New Contact",
@@ -1881,7 +1952,13 @@
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact",
"delete": "Delete"
"delete": "Delete",
"upload_photo": "Upload photo",
"remove_photo": "Remove photo",
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
"photo_too_large": "Image is too large (max 10 MB)",
"photo_invalid": "Invalid image file",
"change_photo": "Change"
},
"groups": {
"create": "New Group",
@@ -1941,6 +2018,37 @@
"error_create": "Failed to create contact",
"error_update": "Failed to update contact",
"error_delete": "Failed to delete contact"
},
"context_menu": {
"open": "Open",
"edit": "Edit",
"send_email": "Send email",
"add_to_group": "Add to group",
"export_vcard": "Export as vCard",
"delete": "Delete",
"call": "Call",
"duplicate": "Duplicate",
"print": "Print"
},
"filters": {
"toggle": "Filters",
"select": "Select",
"clear": "Clear",
"close": "Close",
"title": "Advanced filters",
"organization": "Company",
"organization_placeholder": "e.g. Acme Corp",
"job_title": "Job title",
"job_title_placeholder": "e.g. Designer",
"location": "Location",
"location_placeholder": "City or country",
"email_domain": "Email domain",
"email_domain_placeholder": "example.com",
"birthday_month": "Birthday in",
"any_month": "Any month",
"has_email": "Has email",
"has_phone": "Has phone",
"has_photo": "Has photo"
}
},
"calendar": {
@@ -1975,7 +2083,10 @@
"resize": "Resize event",
"duplicate": "Duplicate",
"today_header": "Today",
"tomorrow_header": "Tomorrow"
"tomorrow_header": "Tomorrow",
"export_ics": "Export as .ics",
"copy_title": "Copy title",
"copy_link": "Copy meeting link"
},
"detail": {
"add_note": "Add a note...",
@@ -2013,6 +2124,7 @@
"tentative": "Tentative",
"needs_action": "Needs action",
"remove": "Remove",
"edit": "Edit",
"email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants",
"status_summary": "{accepted} accepted, {pending} pending",
@@ -2122,7 +2234,10 @@
"rsvp_error": "Failed to update response",
"event_duplicated": "Event duplicated",
"event_error": "Failed to save event",
"task_due": "Task due"
"task_due": "Task due",
"event_exported": "Event exported",
"title_copied": "Title copied",
"link_copied": "Link copied"
},
"status": {
"loading_calendars": "Loading calendars...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Carpetas",
"mail": "Correo",
"nav_label": "Navegación",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Compartido"
},
"sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral",
@@ -670,14 +671,24 @@
"files": "Archivos",
"contacts": "Contactos",
"sidebar_apps": "Apps de barra lateral",
"notifications": "Notificaciones"
"notifications": "Notificaciones",
"layout": "Diseño",
"reading": "Lectura",
"composing": "Redacción",
"content_senders": "Contenido y remitentes",
"about_data": "Acerca de y datos",
"debug": "Depuración"
},
"tab_groups": {
"general": "General",
"account": "Cuenta e identidad",
"organization": "Organización del correo",
"apps": "Aplicaciones",
"system": "Sistema"
"system": "Sistema",
"appearance": "Apariencia",
"mail": "Correo",
"privacy": "Privacidad y seguridad",
"advanced": "Avanzado"
},
"appearance": {
"title": "Apariencia",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Buzón unificado",
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
},
"colorful_sidebar_icons": {
"label": "Iconos de barra lateral a color",
"description": "Colorea los iconos de carpetas y etiquetas según su tipo (azul para Bandeja de entrada, rojo para Spam, verde para Enviados, etc.). Desactívalo para una barra lateral monocroma."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Añadir palabra clave...",
"add": "Add",
"remove": "Eliminar"
},
"hide_inline_image_attachments": {
"label": "Ocultar imágenes incrustadas de los adjuntos",
"description": "Las imágenes incrustadas en el cuerpo del mensaje no se listan como adjuntos separados"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Autenticación de dos factores deshabilitada",
"enable_error": "No se pudo habilitar 2FA",
"disable_error": "No se pudo deshabilitar 2FA",
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):"
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):",
"verification_code": "Código de verificación",
"confirm": "Confirmar",
"disable": "Desactivar",
"disable_confirm_prompt": "Introduce tu contraseña para desactivar la autenticación de dos factores.",
"password_required": "Se requiere la contraseña",
"code_required": "Se requiere el código de verificación",
"code_invalid": "Código de verificación no válido. Revisa tu aplicación de autenticación e inténtalo de nuevo."
},
"app_passwords": {
"title": "Contraseñas de aplicación",
@@ -1088,7 +1114,25 @@
"removed": "Contraseña de aplicación eliminada",
"add_error": "No se pudo crear la contraseña de aplicación",
"remove_error": "No se pudo eliminar la contraseña de aplicación",
"none": "No hay contraseñas de aplicación configuradas"
"none": "No hay contraseñas de aplicación configuradas",
"done": "Hecho",
"expires_label": "Caduca (opcional)",
"copy_now_warning": "Copia esta contraseña ahora - no se volverá a mostrar.",
"allowed_ips_label": "IPs permitidas (opcional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Separadas por coma o espacio. Dejar vacío para permitir cualquier IP."
},
"api_keys": {
"title": "Claves API",
"description": "Crea claves API para scripts e integraciones que se comunican directamente con el servidor",
"name_label": "Nombre de la clave",
"name_placeholder": "p. ej. Script de copia de seguridad, CI runner",
"copy_now_warning": "Copia esta clave API ahora - no se mostrará de nuevo.",
"added": "Clave API creada",
"removed": "Clave API eliminada",
"add_error": "No se pudo crear la clave API",
"remove_error": "No se pudo eliminar la clave API",
"none": "No hay claves API configuradas"
},
"encryption": {
"section_title": "Cifrado en reposo",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condición} other {# condiciones}}",
"actions_count": "{count, plural, one {# acción} other {# acciones}}"
}
},
"origin_external": "Externo",
"managed_by_tooltip": "Gestionado por {source}. Edítalo en esa aplicación o usa el editor Sieve sin procesar."
},
"templates": {
"title": "Plantillas de correo",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
"empty_search": "Ningún contacto coincide con tu búsqueda",
"empty_search_hint": "Prueba con otro término de búsqueda",
"empty_filtered": "Ningún contacto coincide con tus filtros",
"empty_filtered_hint": "Ajusta o limpia los filtros",
"clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Eliminar contacto",
@@ -1778,7 +1826,33 @@
"import_to_smime": "Importar a S/MIME",
"cert_already_imported": "Certificado ya importado",
"cert_imported": "Certificado importado",
"cert_import_failed": "Error al importar el certificado"
"cert_import_failed": "Error al importar el certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Más acciones",
"age_years": "{count, plural, one {1 año} other {# años}}",
"years_since": "{count, plural, one {hace 1 año} other {hace # años}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nuevo contacto",
@@ -1874,7 +1948,13 @@
"email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto",
"delete": "Eliminar"
"delete": "Eliminar",
"upload_photo": "Subir foto",
"remove_photo": "Quitar foto",
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
"photo_invalid": "Archivo de imagen no válido",
"change_photo": "Cambiar"
},
"groups": {
"create": "Nuevo grupo",
@@ -1934,6 +2014,37 @@
"error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar correo",
"add_to_group": "Añadir al grupo",
"export_vcard": "Exportar como vCard",
"delete": "Eliminar",
"call": "Llamar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Seleccionar",
"clear": "Borrar",
"close": "Cerrar",
"title": "Filtros avanzados",
"organization": "Empresa",
"organization_placeholder": "p. ej. Acme",
"job_title": "Puesto",
"job_title_placeholder": "p. ej. Diseñador",
"location": "Ubicación",
"location_placeholder": "Ciudad o país",
"email_domain": "Dominio de correo",
"email_domain_placeholder": "ejemplo.com",
"birthday_month": "Cumpleaños en",
"any_month": "Cualquier mes",
"has_email": "Con correo",
"has_phone": "Con teléfono",
"has_photo": "Con foto"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoy",
"tomorrow_header": "Mañana"
"tomorrow_header": "Mañana",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar enlace de reunión"
},
"detail": {
"add_note": "Añadir una nota...",
@@ -2006,6 +2120,7 @@
"tentative": "Provisional",
"needs_action": "Pendiente de respuesta",
"remove": "Eliminar",
"edit": "Editar",
"email_placeholder": "Añadir dirección de correo o buscar contactos",
"send_invitations": "Enviar invitaciones a los participantes",
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Error al actualizar la respuesta",
"event_duplicated": "Evento duplicado",
"event_error": "Error al guardar el evento",
"task_due": "Tarea vencida"
"task_due": "Tarea vencida",
"event_exported": "Evento exportado",
"title_copied": "Título copiado",
"link_copied": "Enlace copiado"
},
"status": {
"loading_calendars": "Cargando calendarios...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Dossiers",
"mail": "Messagerie",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Partagé"
},
"sidebar_apps": {
"modal_title": "Applications de la barre latérale",
@@ -670,14 +671,24 @@
"files": "Fichiers",
"contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale",
"notifications": "Notifications"
"notifications": "Notifications",
"layout": "Mise en page",
"reading": "Lecture",
"composing": "Rédaction",
"content_senders": "Contenu et expéditeurs",
"about_data": "À propos et données",
"debug": "Débogage"
},
"tab_groups": {
"general": "Général",
"account": "Compte & Identité",
"organization": "Organisation des e-mails",
"apps": "Applications",
"system": "Système"
"system": "Système",
"appearance": "Apparence",
"mail": "Courrier",
"privacy": "Confidentialité et sécurité",
"advanced": "Avancé"
},
"appearance": {
"title": "Apparence",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
},
"colorful_sidebar_icons": {
"label": "Icônes colorées dans la barre latérale",
"description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Ajouter un mot-clé...",
"add": "Add",
"remove": "Supprimer"
},
"hide_inline_image_attachments": {
"label": "Masquer les images intégrées des pièces jointes",
"description": "Les images intégrées au corps du message ne sont pas listées comme pièces jointes séparées"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Authentification à deux facteurs désactivée",
"enable_error": "Impossible d'activer la 2FA",
"disable_error": "Impossible de désactiver la 2FA",
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :"
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :",
"verification_code": "Code de vérification",
"confirm": "Confirmer",
"disable": "Désactiver",
"disable_confirm_prompt": "Saisissez votre mot de passe pour désactiver l'authentification à deux facteurs.",
"password_required": "Le mot de passe est requis",
"code_required": "Le code de vérification est requis",
"code_invalid": "Code de vérification non valide. Vérifiez votre application d'authentification et réessayez."
},
"app_passwords": {
"title": "Mots de passe d'application",
@@ -1088,7 +1114,25 @@
"removed": "Mot de passe d'application supprimé",
"add_error": "Impossible de créer le mot de passe d'application",
"remove_error": "Impossible de supprimer le mot de passe d'application",
"none": "Aucun mot de passe d'application configuré"
"none": "Aucun mot de passe d'application configuré",
"done": "Terminé",
"expires_label": "Expire (facultatif)",
"copy_now_warning": "Copiez ce mot de passe maintenant - il ne sera plus affiché.",
"allowed_ips_label": "IP autorisées (facultatif)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Séparées par virgule ou espace. Laisser vide pour autoriser toute IP."
},
"api_keys": {
"title": "Clés API",
"description": "Créez des clés API pour les scripts et intégrations qui communiquent directement avec le serveur",
"name_label": "Nom de la clé",
"name_placeholder": "ex. Script de sauvegarde, CI runner",
"copy_now_warning": "Copiez cette clé API maintenant - elle ne sera plus affichée.",
"added": "Clé API créée",
"removed": "Clé API supprimée",
"add_error": "Échec de la création de la clé API",
"remove_error": "Échec de la suppression de la clé API",
"none": "Aucune clé API configurée"
},
"encryption": {
"section_title": "Chiffrement au repos",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"actions_count": "{count, plural, one {# action} other {# actions}}"
}
},
"origin_external": "Externe",
"managed_by_tooltip": "Géré par {source}. Modifiez-le dans cette application, ou utilisez l'éditeur Sieve brut."
},
"templates": {
"title": "Modèles d'e-mails",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_search": "Aucun contact ne correspond à votre recherche",
"empty_search_hint": "Essayez un autre terme de recherche",
"empty_filtered": "Aucun contact ne correspond à vos filtres",
"empty_filtered_hint": "Ajustez ou effacez les filtres",
"clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard",
"delete_confirm_title": "Supprimer le contact",
@@ -1778,7 +1826,33 @@
"import_to_smime": "Importer dans S/MIME",
"cert_already_imported": "Certificat déjà importé",
"cert_imported": "Certificat importé",
"cert_import_failed": "Échec de l'import du certificat"
"cert_import_failed": "Échec de l'import du certificat",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Plus d'actions",
"age_years": "{count, plural, one {1 an} other {# ans}}",
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nouveau contact",
@@ -1874,7 +1948,13 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact",
"delete": "Supprimer"
"delete": "Supprimer",
"upload_photo": "Téléverser une photo",
"remove_photo": "Supprimer la photo",
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
"photo_too_large": "Limage est trop grande (max 10 Mo)",
"photo_invalid": "Fichier image invalide",
"change_photo": "Modifier"
},
"groups": {
"create": "Nouveau groupe",
@@ -1934,6 +2014,37 @@
"error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact"
},
"context_menu": {
"open": "Ouvrir",
"edit": "Modifier",
"send_email": "Envoyer un courriel",
"add_to_group": "Ajouter au groupe",
"export_vcard": "Exporter en vCard",
"delete": "Supprimer",
"call": "Appeler",
"duplicate": "Dupliquer",
"print": "Imprimer"
},
"filters": {
"toggle": "Filtres",
"select": "Sélectionner",
"clear": "Effacer",
"close": "Fermer",
"title": "Filtres avancés",
"organization": "Entreprise",
"organization_placeholder": "ex. Acme",
"job_title": "Poste",
"job_title_placeholder": "ex. Designer",
"location": "Lieu",
"location_placeholder": "Ville ou pays",
"email_domain": "Domaine e-mail",
"email_domain_placeholder": "exemple.com",
"birthday_month": "Anniversaire en",
"any_month": "Tous les mois",
"has_email": "Avec e-mail",
"has_phone": "Avec téléphone",
"has_photo": "Avec photo"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Redimensionner l'événement",
"duplicate": "Dupliquer",
"today_header": "Aujourd'hui",
"tomorrow_header": "Demain"
"tomorrow_header": "Demain",
"export_ics": "Exporter en .ics",
"copy_title": "Copier le titre",
"copy_link": "Copier le lien de réunion"
},
"detail": {
"add_note": "Ajouter une note...",
@@ -2006,6 +2120,7 @@
"tentative": "Provisoire",
"needs_action": "En attente de réponse",
"remove": "Retirer",
"edit": "Modifier",
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
"send_invitations": "Envoyer les invitations aux participants",
"status_summary": "{accepted} accepté(s), {pending} en attente",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Échec de la mise à jour de la réponse",
"event_duplicated": "Événement dupliqué",
"event_error": "Échec de l'enregistrement de l'événement",
"task_due": "Tâche à échéance"
"task_due": "Tâche à échéance",
"event_exported": "Événement exporté",
"title_copied": "Titre copié",
"link_copied": "Lien copié"
},
"status": {
"loading_calendars": "Chargement des calendriers...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Cartelle",
"mail": "Posta",
"nav_label": "Navigazione",
"add_app": "App"
"add_app": "App",
"shared": "Condiviso"
},
"sidebar_apps": {
"modal_title": "App della barra laterale",
@@ -670,14 +671,24 @@
"files": "File",
"contacts": "Contatti",
"sidebar_apps": "App nella barra laterale",
"notifications": "Notifiche"
"notifications": "Notifiche",
"layout": "Layout",
"reading": "Lettura",
"composing": "Composizione",
"content_senders": "Contenuto e mittenti",
"about_data": "Informazioni e dati",
"debug": "Debug"
},
"tab_groups": {
"general": "Generale",
"account": "Account e identità",
"organization": "Organizzazione e-mail",
"apps": "Applicazioni",
"system": "Sistema"
"system": "Sistema",
"appearance": "Aspetto",
"mail": "Posta",
"privacy": "Privacy e sicurezza",
"advanced": "Avanzate"
},
"appearance": {
"title": "Aspetto",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Casella di posta unificata",
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
},
"colorful_sidebar_icons": {
"label": "Icone colorate nella barra laterale",
"description": "Colora le icone di cartelle ed etichette per tipo (Posta in arrivo blu, Spam rosso, Inviati verde, ecc.). Disattiva per una barra laterale monocromatica."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Aggiungi parola chiave...",
"add": "Add",
"remove": "Rimuovi"
},
"hide_inline_image_attachments": {
"label": "Nascondi le immagini inline dagli allegati",
"description": "Le immagini incorporate nel corpo del messaggio non vengono elencate come allegati separati"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Autenticazione a due fattori disabilitata",
"enable_error": "Impossibile abilitare la 2FA",
"disable_error": "Impossibile disabilitare la 2FA",
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):"
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):",
"verification_code": "Codice di verifica",
"confirm": "Conferma",
"disable": "Disattiva",
"disable_confirm_prompt": "Inserisci la tua password per disattivare l'autenticazione a due fattori.",
"password_required": "La password è obbligatoria",
"code_required": "Il codice di verifica è obbligatorio",
"code_invalid": "Codice di verifica non valido. Controlla la tua app di autenticazione e riprova."
},
"app_passwords": {
"title": "Password per le app",
@@ -1088,7 +1114,25 @@
"removed": "Password per l'app rimossa",
"add_error": "Impossibile creare la password per l'app",
"remove_error": "Impossibile rimuovere la password per l'app",
"none": "Nessuna password per le app configurata"
"none": "Nessuna password per le app configurata",
"done": "Fatto",
"expires_label": "Scadenza (facoltativa)",
"copy_now_warning": "Copia questa password ora - non verrà più mostrata.",
"allowed_ips_label": "IP consentiti (opzionale)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Separati da virgola o spazio. Lasciare vuoto per consentire qualsiasi IP."
},
"api_keys": {
"title": "Chiavi API",
"description": "Crea chiavi API per script e integrazioni che comunicano direttamente con il server",
"name_label": "Nome chiave",
"name_placeholder": "es. Script di backup, CI runner",
"copy_now_warning": "Copia subito questa chiave API - non verrà mostrata di nuovo.",
"added": "Chiave API creata",
"removed": "Chiave API rimossa",
"add_error": "Impossibile creare la chiave API",
"remove_error": "Impossibile rimuovere la chiave API",
"none": "Nessuna chiave API configurata"
},
"encryption": {
"section_title": "Crittografia a riposo",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condizione} other {# condizioni}}",
"actions_count": "{count, plural, one {# azione} other {# azioni}}"
}
},
"origin_external": "Esterno",
"managed_by_tooltip": "Gestito da {source}. Modificalo in quell'app o usa l'editor Sieve grezzo."
},
"templates": {
"title": "Modelli email",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_search": "Nessun contatto corrisponde alla ricerca",
"empty_search_hint": "Prova con un altro termine di ricerca",
"empty_filtered": "Nessun contatto corrisponde ai filtri",
"empty_filtered_hint": "Modifica o cancella i filtri",
"clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard",
"delete_confirm_title": "Elimina contatto",
@@ -1778,7 +1826,33 @@
"import_to_smime": "Importa in S/MIME",
"cert_already_imported": "Certificato già importato",
"cert_imported": "Certificato importato",
"cert_import_failed": "Importazione del certificato non riuscita"
"cert_import_failed": "Importazione del certificato non riuscita",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Altre azioni",
"age_years": "{count, plural, one {1 anno} other {# anni}}",
"years_since": "{count, plural, one {1 anno fa} other {# anni fa}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nuovo contatto",
@@ -1874,7 +1948,13 @@
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto",
"delete": "Elimina"
"delete": "Elimina",
"upload_photo": "Carica foto",
"remove_photo": "Rimuovi foto",
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
"photo_invalid": "File immagine non valido",
"change_photo": "Cambia"
},
"groups": {
"create": "Nuovo gruppo",
@@ -1934,6 +2014,37 @@
"error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto"
},
"context_menu": {
"open": "Apri",
"edit": "Modifica",
"send_email": "Invia email",
"add_to_group": "Aggiungi al gruppo",
"export_vcard": "Esporta come vCard",
"delete": "Elimina",
"call": "Chiama",
"duplicate": "Duplica",
"print": "Stampa"
},
"filters": {
"toggle": "Filtri",
"select": "Seleziona",
"clear": "Azzera",
"close": "Chiudi",
"title": "Filtri avanzati",
"organization": "Azienda",
"organization_placeholder": "es. Acme",
"job_title": "Ruolo",
"job_title_placeholder": "es. Designer",
"location": "Luogo",
"location_placeholder": "Città o paese",
"email_domain": "Dominio email",
"email_domain_placeholder": "esempio.com",
"birthday_month": "Compleanno in",
"any_month": "Qualsiasi mese",
"has_email": "Con email",
"has_phone": "Con telefono",
"has_photo": "Con foto"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Ridimensiona evento",
"duplicate": "Duplica",
"today_header": "Oggi",
"tomorrow_header": "Domani"
"tomorrow_header": "Domani",
"export_ics": "Esporta come .ics",
"copy_title": "Copia titolo",
"copy_link": "Copia link riunione"
},
"detail": {
"add_note": "Aggiungi una nota...",
@@ -2006,6 +2120,7 @@
"tentative": "Provvisorio",
"needs_action": "In attesa di risposta",
"remove": "Rimuovi",
"edit": "Modifica",
"email_placeholder": "Aggiungi indirizzo email o cerca contatti",
"send_invitations": "Invia inviti ai partecipanti",
"status_summary": "{accepted} accettato/i, {pending} in attesa",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Impossibile aggiornare la risposta",
"event_duplicated": "Evento duplicato",
"event_error": "Salvataggio dell'evento non riuscito",
"task_due": "Attività in scadenza"
"task_due": "Attività in scadenza",
"event_exported": "Evento esportato",
"title_copied": "Titolo copiato",
"link_copied": "Link copiato"
},
"status": {
"loading_calendars": "Caricamento calendari...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "フォルダ",
"mail": "メール",
"nav_label": "ナビゲーション",
"add_app": "アプリ"
"add_app": "アプリ",
"shared": "共有"
},
"sidebar_apps": {
"modal_title": "サイドバーアプリ",
@@ -670,14 +671,24 @@
"files": "ファイル",
"contacts": "連絡先",
"sidebar_apps": "サイドバーアプリ",
"notifications": "通知"
"notifications": "通知",
"layout": "レイアウト",
"reading": "閲覧",
"composing": "作成",
"content_senders": "コンテンツと送信者",
"about_data": "情報とデータ",
"debug": "デバッグ"
},
"tab_groups": {
"general": "一般",
"account": "アカウントと身元",
"organization": "メール整理",
"apps": "アプリ",
"system": "システム"
"system": "システム",
"appearance": "外観",
"mail": "メール",
"privacy": "プライバシーとセキュリティ",
"advanced": "詳細"
},
"appearance": {
"title": "外観",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
},
"colorful_sidebar_icons": {
"label": "カラフルなサイドバーアイコン",
"description": "フォルダーとタグのアイコンを種類別に色分けします(受信トレイは青、迷惑メールは赤、送信済みは緑など)。モノクロのサイドバーにするには無効にしてください。"
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "キーワードを追加...",
"add": "Add",
"remove": "削除"
},
"hide_inline_image_attachments": {
"label": "添付ファイル一覧からインライン画像を隠す",
"description": "本文に埋め込まれた画像を個別の添付ファイルとして表示しません"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "二要素認証が無効になりました",
"enable_error": "2FAを有効にできませんでした",
"disable_error": "2FAを無効にできませんでした",
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:"
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:",
"verification_code": "確認コード",
"confirm": "確認",
"disable": "無効化",
"disable_confirm_prompt": "二要素認証を無効にするにはパスワードを入力してください。",
"password_required": "パスワードが必要です",
"code_required": "確認コードが必要です",
"code_invalid": "確認コードが無効です。認証アプリを確認してもう一度お試しください。"
},
"app_passwords": {
"title": "アプリパスワード",
@@ -1088,7 +1114,25 @@
"removed": "アプリパスワードが削除されました",
"add_error": "アプリパスワードを作成できませんでした",
"remove_error": "アプリパスワードを削除できませんでした",
"none": "アプリパスワードは設定されていません"
"none": "アプリパスワードは設定されていません",
"done": "完了",
"expires_label": "有効期限(任意)",
"copy_now_warning": "今すぐこのパスワードをコピーしてください - 再表示されません。",
"allowed_ips_label": "許可するIP(任意)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "カンマまたは空白で区切ります。空欄の場合、すべてのIPを許可します。"
},
"api_keys": {
"title": "APIキー",
"description": "サーバーと直接通信するスクリプトや連携用にAPIキーを作成します",
"name_label": "キー名",
"name_placeholder": "例: バックアップスクリプト、CIランナー",
"copy_now_warning": "このAPIキーを今すぐコピーしてください - 二度と表示されません。",
"added": "APIキーを作成しました",
"removed": "APIキーを削除しました",
"add_error": "APIキーの作成に失敗しました",
"remove_error": "APIキーの削除に失敗しました",
"none": "APIキーは設定されていません"
},
"encryption": {
"section_title": "保存時の暗号化",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, other {#個の条件}}",
"actions_count": "{count, plural, other {#個のアクション}}"
}
},
"origin_external": "外部",
"managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。"
},
"templates": {
"title": "メールテンプレート",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_search": "検索に一致する連絡先がありません",
"empty_search_hint": "別の検索語をお試しください",
"empty_filtered": "フィルターに一致する連絡先がありません",
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
"clear_search": "検索をクリア",
"import_vcard": "vCardをインポート",
"delete_confirm_title": "連絡先を削除",
@@ -1778,7 +1826,33 @@
"import_to_smime": "S/MIME にインポート",
"cert_already_imported": "証明書はすでにインポートされています",
"cert_imported": "証明書をインポートしました",
"cert_import_failed": "証明書のインポートに失敗しました"
"cert_import_failed": "証明書のインポートに失敗しました",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "その他の操作",
"age_years": "{count}歳",
"years_since": "{count}年前"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "新しい連絡先",
@@ -1874,7 +1948,13 @@
"email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました",
"delete": "削除"
"delete": "削除",
"upload_photo": "写真をアップロード",
"remove_photo": "写真を削除",
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
"photo_too_large": "画像が大きすぎます (最大10MB)",
"photo_invalid": "無効な画像ファイル",
"change_photo": "変更"
},
"groups": {
"create": "新しいグループ",
@@ -1934,6 +2014,37 @@
"error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました"
},
"context_menu": {
"open": "開く",
"edit": "編集",
"send_email": "メール送信",
"add_to_group": "グループに追加",
"export_vcard": "vCardとしてエクスポート",
"delete": "削除",
"call": "電話する",
"duplicate": "複製",
"print": "印刷"
},
"filters": {
"toggle": "フィルター",
"select": "選択",
"clear": "クリア",
"close": "閉じる",
"title": "詳細フィルター",
"organization": "会社",
"organization_placeholder": "例: Acme",
"job_title": "役職",
"job_title_placeholder": "例: デザイナー",
"location": "場所",
"location_placeholder": "都市または国",
"email_domain": "メールドメイン",
"email_domain_placeholder": "example.com",
"birthday_month": "誕生月",
"any_month": "すべての月",
"has_email": "メールあり",
"has_phone": "電話あり",
"has_photo": "写真あり"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "イベントのサイズ変更",
"duplicate": "複製",
"today_header": "今日",
"tomorrow_header": "明日"
"tomorrow_header": "明日",
"export_ics": ".icsとしてエクスポート",
"copy_title": "タイトルをコピー",
"copy_link": "会議リンクをコピー"
},
"detail": {
"add_note": "メモを追加...",
@@ -2006,6 +2120,7 @@
"tentative": "仮承諾",
"needs_action": "未回答",
"remove": "削除",
"edit": "編集",
"email_placeholder": "メールアドレスを追加または連絡先を検索",
"send_invitations": "参加者に招待を送信",
"status_summary": "{accepted}人承諾、{pending}人保留",
@@ -2115,7 +2230,10 @@
"rsvp_error": "回答の更新に失敗しました",
"event_duplicated": "予定を複製しました",
"event_error": "予定の保存に失敗しました",
"task_due": "タスクの期限です"
"task_due": "タスクの期限です",
"event_exported": "イベントをエクスポートしました",
"title_copied": "タイトルをコピーしました",
"link_copied": "リンクをコピーしました"
},
"status": {
"loading_calendars": "カレンダーを読み込み中...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "폴더",
"mail": "메일",
"nav_label": "내비게이션",
"add_app": "앱"
"add_app": "앱",
"shared": "공유됨"
},
"sidebar_apps": {
"modal_title": "사이드바 앱",
@@ -670,14 +671,24 @@
"contacts": "연락처",
"encryption": "암호화",
"sidebar_apps": "사이드바 앱",
"notifications": "알림"
"notifications": "알림",
"layout": "레이아웃",
"reading": "읽기",
"composing": "작성",
"content_senders": "콘텐츠 및 발신자",
"about_data": "정보 및 데이터",
"debug": "디버그"
},
"tab_groups": {
"general": "일반",
"account": "계정 및 인증",
"organization": "메일 정리",
"apps": "앱",
"system": "시스템"
"system": "시스템",
"appearance": "모양",
"mail": "메일",
"privacy": "개인정보 및 보안",
"advanced": "고급"
},
"appearance": {
"title": "화면 설정",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
},
"colorful_sidebar_icons": {
"label": "컬러풀한 사이드바 아이콘",
"description": "폴더와 태그 아이콘을 유형별로 색상 표시합니다(받은편지함 파란색, 스팸 빨간색, 보낸편지함 녹색 등). 모노크롬 사이드바를 원하면 비활성화하세요."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "키워드 추가...",
"add": "Add",
"remove": "삭제"
},
"hide_inline_image_attachments": {
"label": "첨부 파일 목록에서 인라인 이미지 숨기기",
"description": "메시지 본문에 포함된 이미지는 별도의 첨부 파일로 표시되지 않습니다"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "2단계 인증이 꺼졌어요",
"enable_error": "2단계 인증을 켜지 못했어요",
"disable_error": "2단계 인증을 끄지 못했어요",
"setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:"
"setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:",
"verification_code": "확인 코드",
"confirm": "확인",
"disable": "비활성화",
"disable_confirm_prompt": "2단계 인증을 비활성화하려면 비밀번호를 입력하세요.",
"password_required": "비밀번호가 필요합니다",
"code_required": "확인 코드가 필요합니다",
"code_invalid": "유효하지 않은 확인 코드입니다. 인증 앱을 확인하고 다시 시도하세요."
},
"app_passwords": {
"title": "앱 비밀번호",
@@ -1088,7 +1114,25 @@
"removed": "앱 비밀번호가 삭제되었어요",
"add_error": "앱 비밀번호를 만들지 못했어요",
"remove_error": "앱 비밀번호를 삭제하지 못했어요",
"none": "설정된 앱 비밀번호가 없어요"
"none": "설정된 앱 비밀번호가 없어요",
"done": "완료",
"expires_label": "만료 (선택 사항)",
"copy_now_warning": "지금 이 비밀번호를 복사하세요 - 다시 표시되지 않습니다.",
"allowed_ips_label": "허용된 IP(선택)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "쉼표 또는 공백으로 구분합니다. 비워두면 모든 IP가 허용됩니다."
},
"api_keys": {
"title": "API 키",
"description": "서버와 직접 통신하는 스크립트 및 통합용 API 키를 만듭니다",
"name_label": "키 이름",
"name_placeholder": "예: 백업 스크립트, CI 러너",
"copy_now_warning": "이 API 키를 지금 복사하세요 - 다시 표시되지 않습니다.",
"added": "API 키가 생성되었습니다",
"removed": "API 키가 삭제되었습니다",
"add_error": "API 키 생성에 실패했습니다",
"remove_error": "API 키 삭제에 실패했습니다",
"none": "구성된 API 키가 없습니다"
},
"encryption": {
"section_title": "저장 데이터 암호화",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "조건 {count}개",
"actions_count": "동작 {count}개"
}
},
"origin_external": "외부",
"managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요."
},
"templates": {
"title": "이메일 템플릿",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
"empty_search": "검색된 연락처가 없어요",
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
"empty_filtered": "필터에 일치하는 연락처가 없어요",
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
"clear_search": "검색 지우기",
"import_vcard": "vCard 가져오기",
"delete_confirm_title": "연락처 삭제",
@@ -1778,7 +1826,33 @@
"calendar": "캘린더",
"calendar_uri": "캘린더 URL",
"scheduling_uri": "스케줄링 URL",
"freebusy_uri": "Free/Busy URL"
"freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "작업 더보기",
"age_years": "{count}세",
"years_since": "{count}년 전"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "새 연락처",
@@ -1874,7 +1948,13 @@
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
"email_error_inline": "이메일 형식이 잘못되었어요",
"save_failed": "연락처를 저장하지 못했어요",
"delete": "삭제"
"delete": "삭제",
"upload_photo": "사진 업로드",
"remove_photo": "사진 제거",
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
"photo_invalid": "잘못된 이미지 파일",
"change_photo": "변경"
},
"groups": {
"create": "새 그룹",
@@ -1934,6 +2014,37 @@
"error_create": "연락처를 만들지 못했어요",
"error_update": "연락처를 업데이트하지 못했어요",
"error_delete": "연락처를 삭제하지 못했어요"
},
"context_menu": {
"open": "열기",
"edit": "편집",
"send_email": "이메일 보내기",
"add_to_group": "그룹에 추가",
"export_vcard": "vCard로 내보내기",
"delete": "삭제",
"call": "통화",
"duplicate": "복제",
"print": "인쇄"
},
"filters": {
"toggle": "필터",
"select": "선택",
"clear": "지우기",
"close": "닫기",
"title": "고급 필터",
"organization": "회사",
"organization_placeholder": "예: Acme",
"job_title": "직책",
"job_title_placeholder": "예: 디자이너",
"location": "위치",
"location_placeholder": "도시 또는 국가",
"email_domain": "이메일 도메인",
"email_domain_placeholder": "example.com",
"birthday_month": "생일 월",
"any_month": "모든 달",
"has_email": "이메일 있음",
"has_phone": "전화번호 있음",
"has_photo": "사진 있음"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "일정 크기 조절",
"duplicate": "복제",
"today_header": "오늘",
"tomorrow_header": "내일"
"tomorrow_header": "내일",
"export_ics": ".ics로 내보내기",
"copy_title": "제목 복사",
"copy_link": "회의 링크 복사"
},
"detail": {
"add_note": "메모 추가...",
@@ -2006,6 +2120,7 @@
"tentative": "미정",
"needs_action": "응답 필요",
"remove": "삭제",
"edit": "편집",
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
"send_invitations": "참석자에게 초대장 보내기",
"status_summary": "{accepted}명 수락, {pending}명 대기 중",
@@ -2115,7 +2230,10 @@
"rsvp_error": "응답을 업데이트하지 못했어요",
"event_duplicated": "일정이 복제되었어요",
"event_error": "일정을 저장하지 못했어요",
"task_due": "할 일 기한이 다가와요"
"task_due": "할 일 기한이 다가와요",
"event_exported": "이벤트 내보내기 완료",
"title_copied": "제목이 복사되었습니다",
"link_copied": "링크가 복사되었습니다"
},
"status": {
"loading_calendars": "캘린더를 불러오는 중...",
+129 -11
View File
@@ -129,7 +129,8 @@
"folders": "Mapes",
"mail": "Pasts",
"nav_label": "Navigācija",
"add_app": "Lietotnes"
"add_app": "Lietotnes",
"shared": "Koplietots"
},
"sidebar_apps": {
"modal_title": "Sānu joslas lietotnes",
@@ -670,14 +671,24 @@
"contacts": "Kontakti",
"encryption": "Šifrēšana",
"sidebar_apps": "Sānu joslas lietotnes",
"notifications": "Paziņojumi"
"notifications": "Paziņojumi",
"layout": "Izkārtojums",
"reading": "Lasīšana",
"composing": "Rakstīšana",
"content_senders": "Saturs un sūtītāji",
"about_data": "Par un dati",
"debug": "Atkļūdošana"
},
"tab_groups": {
"general": "Vispārīgi",
"account": "Konts un identitāte",
"organization": "Pasta organizēšana",
"apps": "Lietotnes",
"system": "Sistēma"
"system": "Sistēma",
"appearance": "Izskats",
"mail": "Pasts",
"privacy": "Privātums un drošība",
"advanced": "Papildu"
},
"appearance": {
"title": "Izskats",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Apvienotā pastkaste",
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
},
"colorful_sidebar_icons": {
"label": "Krāsainas sānjoslas ikonas",
"description": "Iekrāsojiet mapju un birku ikonas pēc to veida (zila Iesūtne, sarkana Mēstules, zaļa Nosūtītie utt.). Atspējojiet, lai iegūtu vienkrāsainu sānjoslu."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Pievienot atslēgvārdu...",
"add": "Add",
"remove": "Noņemt"
},
"hide_inline_image_attachments": {
"label": "Slēpt iegultos attēlus no pielikumu saraksta",
"description": "Ziņojuma pamattekstā iegultie attēli netiek rādīti kā atsevišķi pielikumi"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "2FA ir izslēgta",
"enable_error": "Neizdevās iespējot 2FA",
"disable_error": "Neizdevās izslēgt 2FA",
"setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):"
"setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):",
"verification_code": "Verifikācijas kods",
"confirm": "Apstiprināt",
"disable": "Atspējot",
"disable_confirm_prompt": "Ievadiet paroli, lai atspējotu divpakāpju autentifikāciju.",
"password_required": "Nepieciešama parole",
"code_required": "Nepieciešams verifikācijas kods",
"code_invalid": "Nederīgs verifikācijas kods. Pārbaudiet autentifikācijas lietotni un mēģiniet vēlreiz."
},
"app_passwords": {
"title": "Lietotņu paroles",
@@ -1088,7 +1114,25 @@
"removed": "Lietotnes parole izdzēsta",
"add_error": "Neizdevās izveidot lietotnes paroli",
"remove_error": "Neizdevās izdzēst lietotnes paroli",
"none": "Lietotņu paroles nav iestatītas"
"none": "Lietotņu paroles nav iestatītas",
"done": "Gatavs",
"expires_label": "Derīguma termiņš (pēc izvēles)",
"copy_now_warning": "Kopējiet šo paroli tagad - tā vairs netiks rādīta.",
"allowed_ips_label": "Atļautās IP (neobligāti)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Atdalītas ar komatu vai atstarpi. Atstājiet tukšu, lai atļautu jebkuru IP."
},
"api_keys": {
"title": "API atslēgas",
"description": "Izveidojiet API atslēgas skriptiem un integrācijām, kas tieši sazinās ar serveri",
"name_label": "Atslēgas nosaukums",
"name_placeholder": "piem. Dublēšanas skripts, CI palaidējs",
"copy_now_warning": "Kopējiet šo API atslēgu tagad - tā vairs netiks parādīta.",
"added": "API atslēga izveidota",
"removed": "API atslēga noņemta",
"add_error": "Neizdevās izveidot API atslēgu",
"remove_error": "Neizdevās noņemt API atslēgu",
"none": "API atslēgas nav konfigurētas"
},
"encryption": {
"section_title": "Krātuves šifrēšana",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# nosacījums} other {# nosacījumi}}",
"actions_count": "{count, plural, one {# darbība} other {# darbības}}"
}
},
"origin_external": "Ārējs",
"managed_by_tooltip": "Pārvalda {source}. Rediģējiet to šajā lietotnē vai izmantojiet neapstrādāto Sieve redaktoru."
},
"templates": {
"title": "Vēstuļu veidnes",
@@ -1697,6 +1743,8 @@
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
"clear_search": "Notīrīt meklēšanu",
"import_vcard": "vCard imports",
"delete_confirm_title": "Dzēst kontaktu",
@@ -1774,7 +1822,33 @@
"calendar": "Kalendārs",
"calendar_uri": "Kalendāra URL",
"scheduling_uri": "Plānošanas URL",
"freebusy_uri": "Pieejamības URL"
"freebusy_uri": "Pieejamības URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Citas darbības",
"age_years": "{count, plural, one {1 gads} other {# gadi}}",
"years_since": "{count, plural, one {pirms 1 gada} other {pirms # gadiem}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Jauns kontakts",
@@ -1870,7 +1944,13 @@
"email_invalid": "Ievadiet derīgu e-pasta adresi",
"email_error_inline": "Nederīgs e-pasta formāts",
"save_failed": "Neizdevās saglabāt kontaktu",
"delete": "Dzēst"
"delete": "Dzēst",
"upload_photo": "Augšupielādēt foto",
"remove_photo": "Noņemt foto",
"photo_hint": "JPG vai PNG, līdz 10 MB. Tiks mainīts izmērs.",
"photo_too_large": "Attēls ir pārāk liels (maks. 10 MB)",
"photo_invalid": "Nederīgs attēla fails",
"change_photo": "Mainīt"
},
"groups": {
"create": "Jauna grupa",
@@ -1934,7 +2014,38 @@
"rename_category": "Pārdēvēt kategoriju",
"category_name_label": "Kategorijas nosaukums",
"category_renamed": "Kategorija pārdēvēta",
"category_rename_failed": "Neizdevās pārdēvēt kategoriju"
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
"context_menu": {
"open": "Atvērt",
"edit": "Rediģēt",
"send_email": "Sūtīt e-pastu",
"add_to_group": "Pievienot grupai",
"export_vcard": "Eksportēt kā vCard",
"delete": "Dzēst",
"call": "Zvanīt",
"duplicate": "Dublēt",
"print": "Drukāt"
},
"filters": {
"toggle": "Filtri",
"select": "Atlasīt",
"clear": "Notīrīt",
"close": "Aizvērt",
"title": "Paplašinātie filtri",
"organization": "Uzņēmums",
"organization_placeholder": "piem. Acme",
"job_title": "Amats",
"job_title_placeholder": "piem. Dizainers",
"location": "Atrašanās vieta",
"location_placeholder": "Pilsēta vai valsts",
"email_domain": "E-pasta domēns",
"email_domain_placeholder": "piemers.lv",
"birthday_month": "Dzimšanas diena",
"any_month": "Jebkurš mēnesis",
"has_email": "Ar e-pastu",
"has_phone": "Ar tālruni",
"has_photo": "Ar foto"
}
},
"calendar": {
"title": "Kalendārs",
@@ -1967,7 +2078,10 @@
"resize": "Mainīt pasākuma laiku",
"duplicate": "Dublēt",
"today_header": "Šodien",
"tomorrow_header": "Rīt"
"tomorrow_header": "Rīt",
"export_ics": "Eksportēt kā .ics",
"copy_title": "Kopēt nosaukumu",
"copy_link": "Kopēt sapulces saiti"
},
"detail": {
"add_note": "Pievienot piezīmi...",
@@ -2005,6 +2119,7 @@
"tentative": "Pagaidām",
"needs_action": "Nepieciešama atbilde",
"remove": "Noņemt",
"edit": "Rediģēt",
"email_placeholder": "Pievienojiet adresi vai kontaktu",
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
"status_summary": "{accepted} pieņēmuši, {pending} gaida",
@@ -2114,7 +2229,10 @@
"rsvp_error": "Neizdevās atjaunināt atbildi",
"event_duplicated": "Pasākums dublēts",
"event_error": "Neizdevās saglabāt pasākumu",
"task_due": "Uzdevuma termiņš"
"task_due": "Uzdevuma termiņš",
"event_exported": "Notikums eksportēts",
"title_copied": "Nosaukums nokopēts",
"link_copied": "Saite nokopēta"
},
"status": {
"loading_calendars": "Ielādē kalendārus...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Mappen",
"mail": "E-mail",
"nav_label": "Navigatie",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Gedeeld"
},
"sidebar_apps": {
"modal_title": "Zijbalk-apps",
@@ -670,14 +671,24 @@
"files": "Bestanden",
"contacts": "Contacten",
"sidebar_apps": "Zijbalk-apps",
"notifications": "Meldingen"
"notifications": "Meldingen",
"layout": "Indeling",
"reading": "Lezen",
"composing": "Opstellen",
"content_senders": "Inhoud en afzenders",
"about_data": "Over en gegevens",
"debug": "Debuggen"
},
"tab_groups": {
"general": "Algemeen",
"account": "Account & identiteit",
"organization": "E-mailorganisatie",
"apps": "Apps",
"system": "Systeem"
"system": "Systeem",
"appearance": "Weergave",
"mail": "E-mail",
"privacy": "Privacy en beveiliging",
"advanced": "Geavanceerd"
},
"appearance": {
"title": "Uiterlijk",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Gecombineerd postvak",
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
},
"colorful_sidebar_icons": {
"label": "Gekleurde zijbalkpictogrammen",
"description": "Kleur map- en tagpictogrammen op type (blauw Postvak IN, rood Spam, groen Verzonden, enz.). Schakel uit voor een monochrome zijbalk."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Trefwoord toevoegen...",
"add": "Add",
"remove": "Verwijderen"
},
"hide_inline_image_attachments": {
"label": "Inline-afbeeldingen verbergen uit bijlagenlijst",
"description": "Afbeeldingen die in de berichttekst zijn ingesloten worden niet als aparte bijlagen weergegeven"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Tweefactorauthenticatie uitgeschakeld",
"enable_error": "Kan 2FA niet inschakelen",
"disable_error": "Kan 2FA niet uitschakelen",
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):"
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):",
"verification_code": "Verificatiecode",
"confirm": "Bevestigen",
"disable": "Uitschakelen",
"disable_confirm_prompt": "Voer uw wachtwoord in om tweefactorauthenticatie uit te schakelen.",
"password_required": "Wachtwoord is vereist",
"code_required": "Verificatiecode is vereist",
"code_invalid": "Ongeldige verificatiecode. Controleer uw authenticator-app en probeer het opnieuw."
},
"app_passwords": {
"title": "App-wachtwoorden",
@@ -1088,7 +1114,25 @@
"removed": "App-wachtwoord verwijderd",
"add_error": "Kan app-wachtwoord niet aanmaken",
"remove_error": "Kan app-wachtwoord niet verwijderen",
"none": "Geen app-wachtwoorden geconfigureerd"
"none": "Geen app-wachtwoorden geconfigureerd",
"done": "Klaar",
"expires_label": "Verloopt (optioneel)",
"copy_now_warning": "Kopieer dit wachtwoord nu - het wordt niet opnieuw weergegeven.",
"allowed_ips_label": "Toegestane IP's (optioneel)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Gescheiden door komma of spatie. Laat leeg om elk IP toe te staan."
},
"api_keys": {
"title": "API-sleutels",
"description": "Maak API-sleutels voor scripts en integraties die rechtstreeks met de server praten",
"name_label": "Sleutelnaam",
"name_placeholder": "bijv. Back-upscript, CI-runner",
"copy_now_warning": "Kopieer deze API-sleutel nu - hij wordt niet opnieuw getoond.",
"added": "API-sleutel aangemaakt",
"removed": "API-sleutel verwijderd",
"add_error": "API-sleutel aanmaken mislukt",
"remove_error": "API-sleutel verwijderen mislukt",
"none": "Geen API-sleutels geconfigureerd"
},
"encryption": {
"section_title": "Versleuteling in rust",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}",
"actions_count": "{count, plural, one {# actie} other {# acties}}"
}
},
"origin_external": "Extern",
"managed_by_tooltip": "Beheerd door {source}. Bewerk het in die app of gebruik de ruwe Sieve-editor."
},
"templates": {
"title": "E-mailsjablonen",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
"empty_search": "Geen contacten gevonden",
"empty_search_hint": "Probeer een andere zoekterm",
"empty_filtered": "Geen contacten voldoen aan uw filters",
"empty_filtered_hint": "Pas filters aan of wis ze",
"clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren",
"delete_confirm_title": "Contact verwijderen",
@@ -1778,7 +1826,33 @@
"import_to_smime": "Importeren naar S/MIME",
"cert_already_imported": "Certificaat is al geïmporteerd",
"cert_imported": "Certificaat geïmporteerd",
"cert_import_failed": "Importeren van certificaat mislukt"
"cert_import_failed": "Importeren van certificaat mislukt",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Meer acties",
"age_years": "{count, plural, one {1 jaar} other {# jaar}}",
"years_since": "{count, plural, one {1 jaar geleden} other {# jaar geleden}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nieuw contact",
@@ -1874,7 +1948,13 @@
"email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan",
"delete": "Verwijderen"
"delete": "Verwijderen",
"upload_photo": "Foto uploaden",
"remove_photo": "Foto verwijderen",
"photo_hint": "JPG of PNG, tot 10 MB. Wordt verkleind.",
"photo_too_large": "Afbeelding is te groot (max 10 MB)",
"photo_invalid": "Ongeldig afbeeldingsbestand",
"change_photo": "Wijzigen"
},
"groups": {
"create": "Nieuwe groep",
@@ -1934,6 +2014,37 @@
"error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen"
},
"context_menu": {
"open": "Openen",
"edit": "Bewerken",
"send_email": "E-mail verzenden",
"add_to_group": "Aan groep toevoegen",
"export_vcard": "Exporteren als vCard",
"delete": "Verwijderen",
"call": "Bellen",
"duplicate": "Dupliceren",
"print": "Afdrukken"
},
"filters": {
"toggle": "Filters",
"select": "Selecteren",
"clear": "Wissen",
"close": "Sluiten",
"title": "Geavanceerde filters",
"organization": "Bedrijf",
"organization_placeholder": "bijv. Acme",
"job_title": "Functie",
"job_title_placeholder": "bijv. Ontwerper",
"location": "Locatie",
"location_placeholder": "Stad of land",
"email_domain": "E-maildomein",
"email_domain_placeholder": "voorbeeld.nl",
"birthday_month": "Verjaardag in",
"any_month": "Elke maand",
"has_email": "Met e-mail",
"has_phone": "Met telefoon",
"has_photo": "Met foto"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Evenement formaat wijzigen",
"duplicate": "Dupliceren",
"today_header": "Vandaag",
"tomorrow_header": "Morgen"
"tomorrow_header": "Morgen",
"export_ics": "Exporteren als .ics",
"copy_title": "Titel kopiëren",
"copy_link": "Vergaderlink kopiëren"
},
"detail": {
"add_note": "Notitie toevoegen...",
@@ -2006,6 +2120,7 @@
"tentative": "Voorlopig",
"needs_action": "Reactie vereist",
"remove": "Verwijderen",
"edit": "Bewerken",
"email_placeholder": "E-mailadres toevoegen of contacten zoeken",
"send_invitations": "Uitnodigingen sturen naar deelnemers",
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Reactie kon niet worden bijgewerkt",
"event_duplicated": "Evenement gedupliceerd",
"event_error": "Evenement opslaan mislukt",
"task_due": "Taak vervalt"
"task_due": "Taak vervalt",
"event_exported": "Afspraak geëxporteerd",
"title_copied": "Titel gekopieerd",
"link_copied": "Link gekopieerd"
},
"status": {
"loading_calendars": "Agenda's laden...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Foldery",
"mail": "Poczta",
"nav_label": "Nawigacja",
"add_app": "Aplikacje"
"add_app": "Aplikacje",
"shared": "Udostępnione"
},
"sidebar_apps": {
"modal_title": "Aplikacje paska bocznego",
@@ -670,14 +671,24 @@
"contacts": "Kontakty",
"encryption": "Szyfrowanie",
"sidebar_apps": "Aplikacje paska bocznego",
"notifications": "Powiadomienia"
"notifications": "Powiadomienia",
"layout": "Układ",
"reading": "Czytanie",
"composing": "Tworzenie",
"content_senders": "Treść i nadawcy",
"about_data": "O programie i dane",
"debug": "Debugowanie"
},
"tab_groups": {
"general": "Ogólne",
"account": "Konto i tożsamość",
"organization": "Organizacja poczty",
"apps": "Aplikacje",
"system": "System"
"system": "System",
"appearance": "Wygląd",
"mail": "Poczta",
"privacy": "Prywatność i bezpieczeństwo",
"advanced": "Zaawansowane"
},
"appearance": {
"title": "Wygląd",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Wspólna skrzynka",
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
},
"colorful_sidebar_icons": {
"label": "Kolorowe ikony paska bocznego",
"description": "Koloruj ikony folderów i tagów według typu (niebieska Skrzynka odbiorcza, czerwona Spam, zielona Wysłane itp.). Wyłącz, aby uzyskać monochromatyczny pasek boczny."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Dodaj słowo kluczowe...",
"add": "Add",
"remove": "Usuń"
},
"hide_inline_image_attachments": {
"label": "Ukryj obrazy osadzone z listy załączników",
"description": "Obrazy osadzone w treści wiadomości nie są wyświetlane jako osobne załączniki"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Uwierzytelnianie dwuskładnikowe wyłączone",
"enable_error": "Nie udało się włączyć 2FA",
"disable_error": "Nie udało się wyłączyć 2FA",
"setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):"
"setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):",
"verification_code": "Kod weryfikacyjny",
"confirm": "Potwierdź",
"disable": "Wyłącz",
"disable_confirm_prompt": "Wprowadź hasło, aby wyłączyć uwierzytelnianie dwuskładnikowe.",
"password_required": "Hasło jest wymagane",
"code_required": "Kod weryfikacyjny jest wymagany",
"code_invalid": "Nieprawidłowy kod weryfikacyjny. Sprawdź aplikację uwierzytelniającą i spróbuj ponownie."
},
"app_passwords": {
"title": "Hasła aplikacji",
@@ -1088,7 +1114,25 @@
"removed": "Hasło aplikacji zostało usunięte",
"add_error": "Nie udało się utworzyć hasła aplikacji",
"remove_error": "Nie udało się usunąć hasła aplikacji",
"none": "Brak skonfigurowanych haseł aplikacji"
"none": "Brak skonfigurowanych haseł aplikacji",
"done": "Gotowe",
"expires_label": "Wygasa (opcjonalnie)",
"copy_now_warning": "Skopiuj to hasło teraz - nie zostanie ponownie wyświetlone.",
"allowed_ips_label": "Dozwolone adresy IP (opcjonalnie)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Oddzielone przecinkiem lub spacją. Pozostaw puste, aby zezwolić na dowolny IP."
},
"api_keys": {
"title": "Klucze API",
"description": "Twórz klucze API dla skryptów i integracji komunikujących się bezpośrednio z serwerem",
"name_label": "Nazwa klucza",
"name_placeholder": "np. Skrypt kopii zapasowej, CI runner",
"copy_now_warning": "Skopiuj ten klucz API teraz - nie zostanie pokazany ponownie.",
"added": "Klucz API utworzony",
"removed": "Klucz API usunięty",
"add_error": "Nie udało się utworzyć klucza API",
"remove_error": "Nie udało się usunąć klucza API",
"none": "Brak skonfigurowanych kluczy API"
},
"encryption": {
"section_title": "Szyfrowanie danych w spoczynku",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# warunek} other {# warunków}}",
"actions_count": "{count, plural, one {# akcja} other {# akcji}}"
}
},
"origin_external": "Zewnętrzny",
"managed_by_tooltip": "Zarządzane przez {source}. Edytuj w tej aplikacji lub użyj surowego edytora Sieve."
},
"templates": {
"title": "Szablony wiadomości e-mail",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
"empty_search": "Żadne kontakty nie pasują do wyszukiwania",
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
"clear_search": "Wyczyść wyszukiwanie",
"import_vcard": "Importuj vCard",
"delete_confirm_title": "Usuń kontakt",
@@ -1778,7 +1826,33 @@
"calendar": "Kalendarz",
"calendar_uri": "Adres URL kalendarza",
"scheduling_uri": "Adres URL planowania",
"freebusy_uri": "Adres URL wolny/zajęty"
"freebusy_uri": "Adres URL wolny/zajęty",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Więcej działań",
"age_years": "{count, plural, one {1 rok} few {# lata} many {# lat} other {# lat}}",
"years_since": "{count, plural, one {1 rok temu} few {# lata temu} many {# lat temu} other {# lat temu}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nowy kontakt",
@@ -1874,7 +1948,13 @@
"email_invalid": "Wprowadź prawidłowy adres e-mail",
"email_error_inline": "Nieprawidłowy format adresu e-mail",
"save_failed": "Nie udało się zapisać kontaktu",
"delete": "Usuń"
"delete": "Usuń",
"upload_photo": "Prześlij zdjęcie",
"remove_photo": "Usuń zdjęcie",
"photo_hint": "JPG lub PNG, do 10 MB. Zostanie zmniejszone.",
"photo_too_large": "Obraz jest za duży (maks. 10 MB)",
"photo_invalid": "Nieprawidłowy plik obrazu",
"change_photo": "Zmień"
},
"groups": {
"create": "Nowa grupa",
@@ -1934,6 +2014,37 @@
"error_create": "Nie udało się utworzyć kontaktu",
"error_update": "Nie udało się zaktualizować kontaktu",
"error_delete": "Nie udało się usunąć kontaktu"
},
"context_menu": {
"open": "Otwórz",
"edit": "Edytuj",
"send_email": "Wyślij e-mail",
"add_to_group": "Dodaj do grupy",
"export_vcard": "Eksportuj jako vCard",
"delete": "Usuń",
"call": "Zadzwoń",
"duplicate": "Duplikuj",
"print": "Drukuj"
},
"filters": {
"toggle": "Filtry",
"select": "Zaznacz",
"clear": "Wyczyść",
"close": "Zamknij",
"title": "Filtry zaawansowane",
"organization": "Firma",
"organization_placeholder": "np. Acme",
"job_title": "Stanowisko",
"job_title_placeholder": "np. Projektant",
"location": "Lokalizacja",
"location_placeholder": "Miasto lub kraj",
"email_domain": "Domena e-mail",
"email_domain_placeholder": "przyklad.pl",
"birthday_month": "Urodziny w",
"any_month": "Dowolny miesiąc",
"has_email": "Z e-mailem",
"has_phone": "Z telefonem",
"has_photo": "Ze zdjęciem"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Zmień rozmiar wydarzenia",
"duplicate": "Duplikuj",
"today_header": "Dzisiaj",
"tomorrow_header": "Jutro"
"tomorrow_header": "Jutro",
"export_ics": "Eksportuj jako .ics",
"copy_title": "Kopiuj tytuł",
"copy_link": "Kopiuj link do spotkania"
},
"detail": {
"add_note": "Dodaj notatkę...",
@@ -2006,6 +2120,7 @@
"tentative": "Wstępnie",
"needs_action": "Wymaga działania",
"remove": "Usuń",
"edit": "Edytuj",
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
"send_invitations": "Wyślij zaproszenia do uczestników",
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Nie udało się zaktualizować odpowiedzi",
"event_duplicated": "Wydarzenie zduplikowano",
"event_error": "Nie udało się zapisać wydarzenia",
"task_due": "Termin zadania"
"task_due": "Termin zadania",
"event_exported": "Wydarzenie wyeksportowane",
"title_copied": "Tytuł skopiowany",
"link_copied": "Link skopiowany"
},
"status": {
"loading_calendars": "Ładowanie kalendarzy...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Pastas",
"mail": "E-mail",
"nav_label": "Navegação",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Compartilhado"
},
"sidebar_apps": {
"modal_title": "Apps da barra lateral",
@@ -670,14 +671,24 @@
"files": "Arquivos",
"contacts": "Contactos",
"sidebar_apps": "Apps da barra lateral",
"notifications": "Notificações"
"notifications": "Notificações",
"layout": "Layout",
"reading": "Leitura",
"composing": "Composição",
"content_senders": "Conteúdo e remetentes",
"about_data": "Sobre e dados",
"debug": "Depuração"
},
"tab_groups": {
"general": "Geral",
"account": "Conta e identidade",
"organization": "Organização de e-mail",
"apps": "Aplicativos",
"system": "Sistema"
"system": "Sistema",
"appearance": "Aparência",
"mail": "Correio",
"privacy": "Privacidade e segurança",
"advanced": "Avançado"
},
"appearance": {
"title": "Aparência",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Caixa de correio unificada",
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
},
"colorful_sidebar_icons": {
"label": "Ícones coloridos na barra lateral",
"description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Adicionar palavra-chave...",
"add": "Add",
"remove": "Remover"
},
"hide_inline_image_attachments": {
"label": "Ocultar imagens incorporadas dos anexos",
"description": "As imagens incorporadas no corpo da mensagem não são listadas como anexos separados"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Autenticação de dois fatores desabilitada",
"enable_error": "Não foi possível habilitar a 2FA",
"disable_error": "Não foi possível desabilitar a 2FA",
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):"
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):",
"verification_code": "Código de verificação",
"confirm": "Confirmar",
"disable": "Desativar",
"disable_confirm_prompt": "Digite sua senha para desativar a autenticação de dois fatores.",
"password_required": "A senha é obrigatória",
"code_required": "O código de verificação é obrigatório",
"code_invalid": "Código de verificação inválido. Verifique seu aplicativo autenticador e tente novamente."
},
"app_passwords": {
"title": "Senhas de aplicativo",
@@ -1088,7 +1114,25 @@
"removed": "Senha de aplicativo removida",
"add_error": "Não foi possível criar a senha de aplicativo",
"remove_error": "Não foi possível remover a senha de aplicativo",
"none": "Nenhuma senha de aplicativo configurada"
"none": "Nenhuma senha de aplicativo configurada",
"done": "Concluído",
"expires_label": "Expira (opcional)",
"copy_now_warning": "Copie esta senha agora - ela não será exibida novamente.",
"allowed_ips_label": "IPs permitidos (opcional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Separados por vírgula ou espaço. Deixe vazio para permitir qualquer IP."
},
"api_keys": {
"title": "Chaves de API",
"description": "Crie chaves de API para scripts e integrações que se comunicam diretamente com o servidor",
"name_label": "Nome da chave",
"name_placeholder": "ex. Script de backup, CI runner",
"copy_now_warning": "Copie esta chave de API agora - ela não será mostrada novamente.",
"added": "Chave de API criada",
"removed": "Chave de API removida",
"add_error": "Falha ao criar chave de API",
"remove_error": "Falha ao remover chave de API",
"none": "Nenhuma chave de API configurada"
},
"encryption": {
"section_title": "Criptografia em repouso",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condição} other {# condições}}",
"actions_count": "{count, plural, one {# ação} other {# ações}}"
}
},
"origin_external": "Externo",
"managed_by_tooltip": "Gerenciado por {source}. Edite nesse aplicativo ou use o editor Sieve bruto."
},
"templates": {
"title": "Modelos de e-mail",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
"empty_search": "Nenhum contato encontrado",
"empty_search_hint": "Tente outro termo de pesquisa",
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
"empty_filtered_hint": "Ajuste ou limpe os filtros",
"clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Excluir contato",
@@ -1778,7 +1826,33 @@
"import_to_smime": "Importar para S/MIME",
"cert_already_imported": "Certificado já importado",
"cert_imported": "Certificado importado",
"cert_import_failed": "Falha ao importar o certificado"
"cert_import_failed": "Falha ao importar o certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Mais ações",
"age_years": "{count, plural, one {1 ano} other {# anos}}",
"years_since": "{count, plural, one {há 1 ano} other {há # anos}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Novo contato",
@@ -1874,7 +1948,13 @@
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato",
"delete": "Excluir"
"delete": "Excluir",
"upload_photo": "Carregar foto",
"remove_photo": "Remover foto",
"photo_hint": "JPG ou PNG, até 10 MB. Será redimensionada.",
"photo_too_large": "A imagem é muito grande (máx. 10 MB)",
"photo_invalid": "Arquivo de imagem inválido",
"change_photo": "Alterar"
},
"groups": {
"create": "Novo grupo",
@@ -1934,6 +2014,37 @@
"error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir contato"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar e-mail",
"add_to_group": "Adicionar ao grupo",
"export_vcard": "Exportar como vCard",
"delete": "Excluir",
"call": "Ligar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Selecionar",
"clear": "Limpar",
"close": "Fechar",
"title": "Filtros avançados",
"organization": "Empresa",
"organization_placeholder": "ex. Acme",
"job_title": "Cargo",
"job_title_placeholder": "ex. Designer",
"location": "Localização",
"location_placeholder": "Cidade ou país",
"email_domain": "Domínio de e-mail",
"email_domain_placeholder": "exemplo.com",
"birthday_month": "Aniversário em",
"any_month": "Qualquer mês",
"has_email": "Com e-mail",
"has_phone": "Com telefone",
"has_photo": "Com foto"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoje",
"tomorrow_header": "Amanhã"
"tomorrow_header": "Amanhã",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar link da reunião"
},
"detail": {
"add_note": "Adicionar uma nota...",
@@ -2006,6 +2120,7 @@
"tentative": "Provisório",
"needs_action": "Aguardando resposta",
"remove": "Remover",
"edit": "Editar",
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
"send_invitations": "Enviar convites aos participantes",
"status_summary": "{accepted} aceito(s), {pending} pendente(s)",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Falha ao atualizar resposta",
"event_duplicated": "Evento duplicado",
"event_error": "Falha ao salvar o evento",
"task_due": "Tarefa vencendo"
"task_due": "Tarefa vencendo",
"event_exported": "Evento exportado",
"title_copied": "Título copiado",
"link_copied": "Link copiado"
},
"status": {
"loading_calendars": "Carregando calendários...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Папки",
"mail": "Почта",
"nav_label": "Навигация",
"add_app": "Приложения"
"add_app": "Приложения",
"shared": "Общие"
},
"sidebar_apps": {
"modal_title": "Приложения боковой панели",
@@ -670,14 +671,24 @@
"contacts": "Контакты",
"encryption": "Шифрование",
"sidebar_apps": "Приложения боковой панели",
"notifications": "Уведомления"
"notifications": "Уведомления",
"layout": "Макет",
"reading": "Чтение",
"composing": "Написание",
"content_senders": "Содержимое и отправители",
"about_data": "О программе и данные",
"debug": "Отладка"
},
"tab_groups": {
"general": "Общие",
"account": "Аккаунт и удостоверение",
"organization": "Организация почты",
"apps": "Приложения",
"system": "Система"
"system": "Система",
"appearance": "Внешний вид",
"mail": "Почта",
"privacy": "Конфиденциальность и безопасность",
"advanced": "Дополнительно"
},
"appearance": {
"title": "Внешний вид",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
},
"colorful_sidebar_icons": {
"label": "Цветные значки боковой панели",
"description": "Окрашивать значки папок и тегов по типу (синий «Входящие», красный «Спам», зелёный «Отправленные» и т. д.). Отключите для монохромной боковой панели."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Добавить ключевое слово...",
"add": "Add",
"remove": "Удалить"
},
"hide_inline_image_attachments": {
"label": "Скрывать встроенные изображения из вложений",
"description": "Изображения, встроенные в тело сообщения, не отображаются как отдельные вложения"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Двухфакторная аутентификация отключена",
"enable_error": "Не удалось включить 2FA",
"disable_error": "Не удалось отключить 2FA",
"setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):"
"setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):",
"verification_code": "Код подтверждения",
"confirm": "Подтвердить",
"disable": "Отключить",
"disable_confirm_prompt": "Введите пароль, чтобы отключить двухфакторную аутентификацию.",
"password_required": "Требуется пароль",
"code_required": "Требуется код подтверждения",
"code_invalid": "Неверный код подтверждения. Проверьте приложение-аутентификатор и попробуйте снова."
},
"app_passwords": {
"title": "Пароли приложений",
@@ -1088,7 +1114,25 @@
"removed": "Пароль приложения удалён",
"add_error": "Не удалось создать пароль приложения",
"remove_error": "Не удалось удалить пароль приложения",
"none": "Пароли приложений не настроены"
"none": "Пароли приложений не настроены",
"done": "Готово",
"expires_label": "Срок действия (необязательно)",
"copy_now_warning": "Скопируйте этот пароль сейчас - он больше не будет показан.",
"allowed_ips_label": "Разрешённые IP (необязательно)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Через запятую или пробел. Оставьте пустым, чтобы разрешить любой IP."
},
"api_keys": {
"title": "API-ключи",
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
"name_label": "Название ключа",
"name_placeholder": "напр. Скрипт резервного копирования, CI runner",
"copy_now_warning": "Скопируйте этот API-ключ сейчас – он больше не будет показан.",
"added": "API-ключ создан",
"removed": "API-ключ удалён",
"add_error": "Не удалось создать API-ключ",
"remove_error": "Не удалось удалить API-ключ",
"none": "API-ключи не настроены"
},
"encryption": {
"section_title": "Шифрование хранилища",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# условие} other {# условий}}",
"actions_count": "{count, plural, one {# действие} other {# действий}}"
}
},
"origin_external": "Внешнее",
"managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve."
},
"templates": {
"title": "Шаблоны писем",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
"empty_search": "Нет контактов, соответствующих вашему запросу",
"empty_search_hint": "Попробуйте другой поисковый запрос",
"empty_filtered": "Нет контактов, соответствующих фильтрам",
"empty_filtered_hint": "Измените или очистите фильтры",
"clear_search": "Очистить поиск",
"import_vcard": "Импорт vCard",
"delete_confirm_title": "Удалить контакт",
@@ -1778,7 +1826,33 @@
"calendar": "Календарь",
"calendar_uri": "URL календаря",
"scheduling_uri": "URL планирования",
"freebusy_uri": "URL доступности"
"freebusy_uri": "URL доступности",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Другие действия",
"age_years": "{count, plural, one {# год} few {# года} many {# лет} other {# лет}}",
"years_since": "{count, plural, one {# год назад} few {# года назад} many {# лет назад} other {# лет назад}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Новый контакт",
@@ -1874,7 +1948,13 @@
"email_invalid": "Введите корректный адрес электронной почты",
"email_error_inline": "Неверный формат email",
"save_failed": "Не удалось сохранить контакт",
"delete": "Удалить"
"delete": "Удалить",
"upload_photo": "Загрузить фото",
"remove_photo": "Удалить фото",
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
"photo_invalid": "Недопустимый файл изображения",
"change_photo": "Изменить"
},
"groups": {
"create": "Новая группа",
@@ -1934,6 +2014,37 @@
"error_create": "Не удалось создать контакт",
"error_update": "Не удалось обновить контакт",
"error_delete": "Не удалось удалить контакт"
},
"context_menu": {
"open": "Открыть",
"edit": "Изменить",
"send_email": "Отправить письмо",
"add_to_group": "Добавить в группу",
"export_vcard": "Экспортировать как vCard",
"delete": "Удалить",
"call": "Позвонить",
"duplicate": "Дублировать",
"print": "Печать"
},
"filters": {
"toggle": "Фильтры",
"select": "Выбрать",
"clear": "Очистить",
"close": "Закрыть",
"title": "Расширенные фильтры",
"organization": "Компания",
"organization_placeholder": "напр. Acme",
"job_title": "Должность",
"job_title_placeholder": "напр. Дизайнер",
"location": "Местоположение",
"location_placeholder": "Город или страна",
"email_domain": "Домен e-mail",
"email_domain_placeholder": "primer.ru",
"birthday_month": "День рождения в",
"any_month": "Любой месяц",
"has_email": "С эл. почтой",
"has_phone": "С телефоном",
"has_photo": "С фото"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Изменить размер события",
"duplicate": "Дублировать",
"today_header": "Сегодня",
"tomorrow_header": "Завтра"
"tomorrow_header": "Завтра",
"export_ics": "Экспорт в .ics",
"copy_title": "Скопировать название",
"copy_link": "Скопировать ссылку встречи"
},
"detail": {
"add_note": "Добавить заметку...",
@@ -2006,6 +2120,7 @@
"tentative": "Предварительно",
"needs_action": "Требует ответа",
"remove": "Удалить",
"edit": "Редактировать",
"email_placeholder": "Добавьте адрес или найдите контакт",
"send_invitations": "Отправить приглашения участникам",
"status_summary": "{accepted} принято, {pending} ожидает",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Не удалось обновить ответ",
"event_duplicated": "Событие дублировано",
"event_error": "Не удалось сохранить событие",
"task_due": "Срок задачи"
"task_due": "Срок задачи",
"event_exported": "Событие экспортировано",
"title_copied": "Название скопировано",
"link_copied": "Ссылка скопирована"
},
"status": {
"loading_calendars": "Загрузка календарей...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "Папки",
"mail": "Пошта",
"nav_label": "Навігація",
"add_app": "програми"
"add_app": "програми",
"shared": "Спільні"
},
"sidebar_apps": {
"modal_title": "Програми бічної панелі",
@@ -670,14 +671,24 @@
"contacts": "Контакти",
"encryption": "Шифрування",
"sidebar_apps": "Програми бічної панелі",
"notifications": "Сповіщення"
"notifications": "Сповіщення",
"layout": "Макет",
"reading": "Читання",
"composing": "Написання",
"content_senders": "Вміст і відправники",
"about_data": "Про програму та дані",
"debug": "Налагодження"
},
"tab_groups": {
"general": "Загальний",
"account": "Обліковий запис і ідентифікатор",
"organization": "Організація пошти",
"apps": "програми",
"system": "система"
"system": "система",
"appearance": "Вигляд",
"mail": "Пошта",
"privacy": "Конфіденційність і безпека",
"advanced": "Додатково"
},
"appearance": {
"title": "Зовнішній вигляд",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
},
"colorful_sidebar_icons": {
"label": "Кольорові значки бічної панелі",
"description": "Забарвлюйте значки папок і тегів за типом (синя «Вхідні», червоний «Спам», зелена «Надіслані» тощо). Вимкніть для монохромної бічної панелі."
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "Додати ключове слово...",
"add": "додати",
"remove": "видалити"
},
"hide_inline_image_attachments": {
"label": "Приховувати вбудовані зображення з вкладень",
"description": "Зображення, вбудовані в тіло повідомлення, не відображаються як окремі вкладення"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "Двофакторну автентифікацію вимкнено",
"enable_error": "Не вдалося ввімкнути 2FA",
"disable_error": "Не вдалося вимкнути 2FA",
"setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):"
"setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):",
"verification_code": "Код підтвердження",
"confirm": "Підтвердити",
"disable": "Вимкнути",
"disable_confirm_prompt": "Введіть пароль, щоб вимкнути двофакторну автентифікацію.",
"password_required": "Потрібен пароль",
"code_required": "Потрібен код підтвердження",
"code_invalid": "Недійсний код підтвердження. Перевірте додаток автентифікації та спробуйте ще раз."
},
"app_passwords": {
"title": "Паролі програм",
@@ -1088,7 +1114,25 @@
"removed": "Пароль програми видалено",
"add_error": "Не вдалося створити пароль програми",
"remove_error": "Не вдалося видалити пароль програми",
"none": "Паролі програм не налаштовано"
"none": "Паролі програм не налаштовано",
"done": "Готово",
"expires_label": "Термін дії (необов’язково)",
"copy_now_warning": "Скопіюйте цей пароль зараз - він більше не буде показаний.",
"allowed_ips_label": "Дозволені IP (необов’язково)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Через кому або пробіл. Залиште порожнім, щоб дозволити будь-який IP."
},
"api_keys": {
"title": "API-ключі",
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
"name_label": "Назва ключа",
"name_placeholder": "напр. Скрипт резервної копії, CI runner",
"copy_now_warning": "Скопіюйте цей API-ключ зараз – він більше не буде показаний.",
"added": "API-ключ створено",
"removed": "API-ключ видалено",
"add_error": "Не вдалося створити API-ключ",
"remove_error": "Не вдалося видалити API-ключ",
"none": "API-ключі не налаштовано"
},
"encryption": {
"section_title": "Шифрування в спокої",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# умова} few {# умови} many {# умов} other {# умов}}",
"actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}"
}
},
"origin_external": "Зовнішнє",
"managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve."
},
"templates": {
"title": "Шаблони електронної пошти",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
"empty_search": "Жоден контакт не відповідає вашому пошуку",
"empty_search_hint": "Спробуйте інший термін пошуку",
"empty_filtered": "Жоден контакт не відповідає фільтрам",
"empty_filtered_hint": "Змініть або очистіть фільтри",
"clear_search": "Очистити пошук",
"import_vcard": "Імпорт vCard",
"delete_confirm_title": "Видалити контакт",
@@ -1778,7 +1826,33 @@
"calendar": "Календар",
"calendar_uri": "URL-адреса календаря",
"scheduling_uri": "URL-адреса планування",
"freebusy_uri": "Вільний/зайнятий URL"
"freebusy_uri": "Вільний/зайнятий URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Більше дій",
"age_years": "{count, plural, one {# рік} few {# роки} many {# років} other {# років}}",
"years_since": "{count, plural, one {# рік тому} few {# роки тому} many {# років тому} other {# років тому}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Новий контакт",
@@ -1874,7 +1948,13 @@
"email_invalid": "Введіть дійсну електронну адресу",
"email_error_inline": "Недійсний формат електронної пошти",
"save_failed": "Не вдалося зберегти контакт",
"delete": "Видалити"
"delete": "Видалити",
"upload_photo": "Завантажити фото",
"remove_photo": "Видалити фото",
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
"photo_invalid": "Недійсний файл зображення",
"change_photo": "Змінити"
},
"groups": {
"create": "Нова група",
@@ -1934,6 +2014,37 @@
"error_create": "Не вдалося створити контакт",
"error_update": "Не вдалося оновити контакт",
"error_delete": "Не вдалося видалити контакт"
},
"context_menu": {
"open": "Відкрити",
"edit": "Редагувати",
"send_email": "Надіслати лист",
"add_to_group": "Додати до групи",
"export_vcard": "Експортувати як vCard",
"delete": "Видалити",
"call": "Зателефонувати",
"duplicate": "Дублювати",
"print": "Друк"
},
"filters": {
"toggle": "Фільтри",
"select": "Вибрати",
"clear": "Очистити",
"close": "Закрити",
"title": "Розширені фільтри",
"organization": "Компанія",
"organization_placeholder": "напр. Acme",
"job_title": "Посада",
"job_title_placeholder": "напр. Дизайнер",
"location": "Місцезнаходження",
"location_placeholder": "Місто або країна",
"email_domain": "Домен e-mail",
"email_domain_placeholder": "priklad.ua",
"birthday_month": "День народження в",
"any_month": "Будь-який місяць",
"has_email": "З ел. поштою",
"has_phone": "З телефоном",
"has_photo": "З фото"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "Змінити розмір події",
"duplicate": "дублікат",
"today_header": "Сьогодні",
"tomorrow_header": "завтра"
"tomorrow_header": "завтра",
"export_ics": "Експортувати як .ics",
"copy_title": "Скопіювати назву",
"copy_link": "Скопіювати посилання зустрічі"
},
"detail": {
"add_note": "Додати примітку...",
@@ -2006,6 +2120,7 @@
"tentative": "Орієнтовний",
"needs_action": "Потребує дії",
"remove": "видалити",
"edit": "Редагувати",
"email_placeholder": "Додайте електронну адресу або знайдіть контакти",
"send_invitations": "Надішліть запрошення учасникам",
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
@@ -2115,7 +2230,10 @@
"rsvp_error": "Не вдалося оновити відповідь",
"event_duplicated": "Подія дублюється",
"event_error": "Не вдалося зберегти подію",
"task_due": "Термін виконання завдання"
"task_due": "Термін виконання завдання",
"event_exported": "Подію експортовано",
"title_copied": "Назву скопійовано",
"link_copied": "Посилання скопійовано"
},
"status": {
"loading_calendars": "Завантаження календарів...",
+128 -10
View File
@@ -129,7 +129,8 @@
"folders": "文件夹",
"mail": "邮件",
"nav_label": "导航",
"add_app": "应用"
"add_app": "应用",
"shared": "共享"
},
"sidebar_apps": {
"modal_title": "侧边栏应用",
@@ -670,14 +671,24 @@
"contacts": "联系人",
"encryption": "加密",
"sidebar_apps": "侧边栏应用",
"notifications": "通知"
"notifications": "通知",
"layout": "布局",
"reading": "阅读",
"composing": "撰写",
"content_senders": "内容和发件人",
"about_data": "关于和数据",
"debug": "调试"
},
"tab_groups": {
"general": "通用",
"account": "账户与身份",
"organization": "邮件组织",
"apps": "应用",
"system": "系统"
"system": "系统",
"appearance": "外观",
"mail": "邮件",
"privacy": "隐私和安全",
"advanced": "高级"
},
"appearance": {
"title": "外观",
@@ -733,6 +744,10 @@
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
},
"colorful_sidebar_icons": {
"label": "彩色侧边栏图标",
"description": "按类型为文件夹和标签图标着色(蓝色收件箱、红色垃圾邮件、绿色已发送等)。禁用以获得单色侧边栏。"
}
},
"keywords": {
@@ -961,6 +976,10 @@
"add_placeholder": "添加关键词...",
"add": "Add",
"remove": "删除"
},
"hide_inline_image_attachments": {
"label": "在附件列表中隐藏内嵌图片",
"description": "嵌入到邮件正文中的图片不会作为单独的附件显示"
}
},
"composer": {
@@ -1071,7 +1090,14 @@
"disabled": "禁用双因素身份验证",
"enable_error": "无法启用 2FA",
"disable_error": "无法禁用 2FA",
"setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:"
"setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:",
"verification_code": "验证码",
"confirm": "确认",
"disable": "禁用",
"disable_confirm_prompt": "输入密码以禁用双重身份验证。",
"password_required": "需要密码",
"code_required": "需要验证码",
"code_invalid": "验证码无效。请检查您的身份验证应用并重试。"
},
"app_passwords": {
"title": "应用密码",
@@ -1088,7 +1114,25 @@
"removed": "应用密码已删除",
"add_error": "创建应用密码失败",
"remove_error": "无法删除应用密码",
"none": "未配置应用密码"
"none": "未配置应用密码",
"done": "完成",
"expires_label": "过期时间(可选)",
"copy_now_warning": "立即复制此密码--它将不再显示。",
"allowed_ips_label": "允许的 IP(可选)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "用逗号或空格分隔。留空则允许任意 IP。"
},
"api_keys": {
"title": "API 密钥",
"description": "为直接与服务器通信的脚本和集成创建 API 密钥",
"name_label": "密钥名称",
"name_placeholder": "如:备份脚本、CI runner",
"copy_now_warning": "请立即复制此 API 密钥 - 它将不会再次显示。",
"added": "API 密钥已创建",
"removed": "API 密钥已删除",
"add_error": "创建 API 密钥失败",
"remove_error": "删除 API 密钥失败",
"none": "未配置 API 密钥"
},
"encryption": {
"section_title": "静态加密",
@@ -1411,7 +1455,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# 个条件} other {# 个条件}}",
"actions_count": "{count, plural, one {# 个操作} other {# 个操作}}"
}
},
"origin_external": "外部",
"managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。"
},
"templates": {
"title": "邮件模板",
@@ -1701,6 +1747,8 @@
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
"empty_search": "没有符合您搜索条件的联系人",
"empty_search_hint": "尝试不同的搜索词",
"empty_filtered": "没有符合您筛选条件的联系人",
"empty_filtered_hint": "请调整或清除筛选条件",
"clear_search": "清除搜索",
"import_vcard": "导入 vCard",
"delete_confirm_title": "删除联系人",
@@ -1778,7 +1826,33 @@
"calendar": "日历",
"calendar_uri": "日历 URL",
"scheduling_uri": "调度 URL",
"freebusy_uri": "空闲/忙碌 URL"
"freebusy_uri": "空闲/忙碌 URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "更多操作",
"age_years": "{count}岁",
"years_since": "{count}年前"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "新联系人",
@@ -1874,7 +1948,13 @@
"email_invalid": "请输入有效的邮箱地址",
"email_error_inline": "邮箱地址格式无效",
"save_failed": "保存联系人失败",
"delete": "删除"
"delete": "删除",
"upload_photo": "上传照片",
"remove_photo": "移除照片",
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
"photo_too_large": "图像过大 (最大 10MB)",
"photo_invalid": "无效的图像文件",
"change_photo": "更改"
},
"groups": {
"create": "新建群组",
@@ -1934,6 +2014,37 @@
"error_create": "创建联系人失败",
"error_update": "无法更新联系人",
"error_delete": "删除联系人失败"
},
"context_menu": {
"open": "打开",
"edit": "编辑",
"send_email": "发送邮件",
"add_to_group": "加入群组",
"export_vcard": "导出为 vCard",
"delete": "删除",
"call": "拨打电话",
"duplicate": "复制",
"print": "打印"
},
"filters": {
"toggle": "筛选",
"select": "选择",
"clear": "清除",
"close": "关闭",
"title": "高级筛选",
"organization": "公司",
"organization_placeholder": "例如 Acme",
"job_title": "职位",
"job_title_placeholder": "例如 设计师",
"location": "所在地",
"location_placeholder": "城市或国家",
"email_domain": "邮箱域名",
"email_domain_placeholder": "example.com",
"birthday_month": "生日月份",
"any_month": "任意月份",
"has_email": "有邮箱",
"has_phone": "有电话",
"has_photo": "有照片"
}
},
"calendar": {
@@ -1968,7 +2079,10 @@
"resize": "调整事件大小",
"duplicate": "复制",
"today_header": "今天",
"tomorrow_header": "明天"
"tomorrow_header": "明天",
"export_ics": "导出为 .ics",
"copy_title": "复制标题",
"copy_link": "复制会议链接"
},
"detail": {
"add_note": "添加注释...",
@@ -2006,6 +2120,7 @@
"tentative": "暂定",
"needs_action": "待处理",
"remove": "移除",
"edit": "编辑",
"email_placeholder": "添加邮箱地址或搜索联系人",
"send_invitations": "向参与者发送邀请",
"status_summary": "{accepted} 已接受,{pending} 待处理",
@@ -2115,7 +2230,10 @@
"rsvp_error": "无法更新回复",
"event_duplicated": "活动已复制",
"event_error": "无法保存活动",
"task_due": "任务到期"
"task_due": "任务到期",
"event_exported": "日程已导出",
"title_copied": "标题已复制",
"link_copied": "链接已复制"
},
"status": {
"loading_calendars": "正在加载日历...",
+1212 -1291
View File
File diff suppressed because it is too large Load Diff

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