Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477fc8c885 | ||
|
|
84abfe79dd | ||
|
|
8a92d53125 | ||
|
|
6fe4a98b02 | ||
|
|
3186198fad | ||
|
|
5c933a595f | ||
|
|
828ad4df72 | ||
|
|
f720cb3ef8 | ||
|
|
818a02428b | ||
|
|
6ed30059e5 | ||
|
|
a2cb2b2c86 | ||
|
|
1f7cd61fc7 | ||
|
|
070eeeecee | ||
|
|
de35d1d8e8 | ||
|
|
7c5785e9e8 | ||
|
|
54f4d37595 | ||
|
|
71cd826c6f | ||
|
|
e96b9a72e7 | ||
|
|
ce4ebb3dc2 | ||
|
|
6680b65863 | ||
|
|
df64959e60 | ||
|
|
8641275bb4 | ||
|
|
6e78f2a09e | ||
|
|
d429652f72 |
+30
-2
@@ -67,6 +67,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# For Docker, mount a persistent volume at this path.
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
|
||||
# Hostname the server binds to (default: 0.0.0.0)
|
||||
# Set to "::" for IPv6 or "[::]" for dual-stack support.
|
||||
# HOSTNAME=0.0.0.0
|
||||
|
||||
# Port the server listens on (default: 3000)
|
||||
# PORT=3000
|
||||
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
@@ -78,10 +89,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# =============================================================================
|
||||
# Login Page Customization (all optional)
|
||||
# Branding (all optional)
|
||||
# =============================================================================
|
||||
|
||||
# Custom logo images for the login page (PNG, SVG, etc.)
|
||||
# Custom favicon for the browser tab.
|
||||
# Supported formats: SVG (recommended), PNG, ICO.
|
||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
||||
# Can be an absolute URL or a path relative to the public/ directory.
|
||||
# Defaults to the Bulwark favicon if not set.
|
||||
# FAVICON_URL=/branding/my-favicon.svg
|
||||
|
||||
# Custom logos for the sidebar (shown in the main app after login).
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 24×24px, max 128×128px
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# If not set, no logo is shown in the sidebar.
|
||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||
|
||||
# Custom logo images for the login page.
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 32×32px, max 512×512px
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.0 (2026-03-17)
|
||||
|
||||
### Features
|
||||
|
||||
- **Security**: Add S/MIME certificate management with identity bindings, signer auto-import, unlock controls, and compose/viewer sign, encrypt, decrypt, and verification flows
|
||||
- **Email**: Add TNEF (`winmail.dat`) parsing to extract message bodies and attachments from Outlook rich-text emails
|
||||
- **Email**: Add archive organization modes for archiving directly or into year/month subfolders
|
||||
- **Email**: Add an "Always Show Emails in Light Mode" preference to avoid dark-mode conversion issues
|
||||
- **Email**: Apply the 12-hour or 24-hour time format preference consistently across calendar and email surfaces
|
||||
- **Identity**: Add identity refresh behavior in the identity manager so server-side changes stay in sync after edits
|
||||
- **UI**: Add configurable sidebar apps with custom icons plus inline or new-tab launch modes
|
||||
- **Branding**: Add runtime branding options for custom favicon, sidebar logos, and login logos
|
||||
- **Deployment**: Add configurable server listen address support via `HOSTNAME`, including IPv6 and dual-stack guidance
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Improve all-day event handling
|
||||
- **Calendar**: Validate and default persisted calendar view mode values
|
||||
- **UI**: Use configured app names more consistently in metadata and login branding surfaces
|
||||
- **Docker**: Correct `HOSTNAME` formatting in the Docker Compose example
|
||||
- **Metadata**: Correct package author and container vendor metadata
|
||||
|
||||
## 1.3.0 (2026-03-16)
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ LABEL org.opencontainers.image.description="Modern webmail client built with Nex
|
||||
LABEL org.opencontainers.image.source="https://github.com/bulwarkmail/webmail"
|
||||
LABEL org.opencontainers.image.url="https://github.com/bulwarkmail/webmail"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
|
||||
LABEL org.opencontainers.image.vendor="root.cloud"
|
||||
LABEL org.opencontainers.image.vendor="rbm.systems"
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="480" />
|
||||
</picture>
|
||||
|
||||
# Bulwark Webmail
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -25,16 +25,16 @@ Built with Next.js and the JMAP protocol.
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
<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
|
||||
**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
|
||||
**Calendar** - Month, week, day, and agenda views with event management
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -43,14 +43,14 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||
|
||||
**Contacts** — Contact management with groups and vCard support
|
||||
**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
|
||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -62,16 +62,16 @@ Built with Next.js and the JMAP protocol.
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||
|
||||
**Light mode** — Full theme support with intelligent color transformation
|
||||
**Light mode** - Full theme support with intelligent color transformation
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||
|
||||
**Settings** — Appearance, identities, filters, templates, and more
|
||||
**Settings** - Appearance, identities, filters, templates, and more
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -80,7 +80,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||
|
||||
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -94,19 +94,21 @@ Built with Next.js and the JMAP protocol.
|
||||
### Mail
|
||||
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
||||
- **Threading** - Gmail-style inline expansion with thread navigation
|
||||
- **Draft auto-save** with discard confirmation
|
||||
- **Attachments** — upload, download, and inline preview
|
||||
- **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
|
||||
- **Attachments** - upload, download, and inline preview
|
||||
- **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
|
||||
- **Color tags/labels** and star/unstar
|
||||
- **Virtual scrolling** for large mailboxes
|
||||
- **Quick reply** from the viewer
|
||||
- **Sender avatars** — favicon-based with negative caching for performance
|
||||
- **Sender avatars** - favicon-based with negative caching for performance
|
||||
- **Recipient popover** for quick contact interaction
|
||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** — unread and total counts displayed in sidebar
|
||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** - unread and total counts displayed in sidebar
|
||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
|
||||
### Calendar
|
||||
|
||||
@@ -114,8 +116,8 @@ Built with Next.js and the JMAP protocol.
|
||||
- **Event hover preview** popover with details
|
||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
||||
- **Participant scheduling** - iTIP invitations, organizer/attendee UI, RSVP
|
||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview and bulk create
|
||||
- **Notifications** with configurable sound and alert persistence
|
||||
- **Real-time sync** via JMAP push
|
||||
@@ -126,15 +128,15 @@ Built with Next.js and the JMAP protocol.
|
||||
- **Contact groups** with group expansion and member management
|
||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||
- **Bulk operations** — multi-select, delete, group add, export
|
||||
- **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…)
|
||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- **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.)
|
||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||
|
||||
### Files
|
||||
|
||||
@@ -142,37 +144,39 @@ Built with Next.js and the JMAP protocol.
|
||||
- **Upload and download** files with progress tracking and folder upload support
|
||||
- **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
|
||||
- **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
|
||||
- **Bulk operations** - multi-select, delete, move, download
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **External content blocked** by default — trusted senders list for auto-load
|
||||
- **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
|
||||
- **SPF/DKIM/DMARC** status indicators
|
||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
|
||||
- **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** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- **Security headers** - CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
||||
- **Newsletter unsubscribe** (RFC 2369)
|
||||
|
||||
### Interface
|
||||
|
||||
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||
- **Dark and light themes** with intelligent email color transformation
|
||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
||||
- **Keyboard shortcuts** — full navigation without a mouse
|
||||
- **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
|
||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||
- **Customizable toolbar** position and login page branding
|
||||
- **Configurable logo** with light/dark mode variants
|
||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||
- **Sidebar apps** - pin custom tools to the navigation rail and open them inline or in a new tab
|
||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||
- **Storage quota** display
|
||||
- **Shared folders** — multi-account access
|
||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
- **Shared folders** - multi-account access
|
||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
|
||||
### Internationalization
|
||||
|
||||
@@ -183,12 +187,13 @@ Automatic browser detection with persistent preference.
|
||||
### Identity Management
|
||||
|
||||
- **Multiple sender identities** with per-identity signatures
|
||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Identity refresh** - keep the identity manager aligned with server-side changes after edits
|
||||
- **Identity badges** in viewer and email list
|
||||
|
||||
### Operations
|
||||
|
||||
- **Automatic update check** — server logs when a newer release is available
|
||||
- **Automatic update check** - server logs when a newer release is available
|
||||
|
||||
---
|
||||
|
||||
@@ -206,7 +211,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
|
||||
```
|
||||
|
||||
@@ -217,7 +222,7 @@ 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
|
||||
```
|
||||
|
||||
@@ -241,7 +246,17 @@ JMAP_SERVER_URL=https://mail.example.com
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
|
||||
```env
|
||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||
PORT=3000 # Default listen port
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OAuth2/OIDC (SSO)</summary>
|
||||
@@ -298,7 +313,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
||||
|
||||
## 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 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
-301
@@ -1,301 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
|
||||
# Bulwark Webmail - Roadmap
|
||||
|
||||
This document tracks the development status and planned features for Bulwark Webmail.
|
||||
|
||||
## Completed Features
|
||||
|
||||
### Core Infrastructure
|
||||
|
||||
- [x] Next.js 16 with TypeScript and App Router
|
||||
- [x] Tailwind CSS v4 with Oxide engine
|
||||
- [x] Zustand state management
|
||||
- [x] Custom JMAP client implementation (RFC 8620)
|
||||
|
||||
### Authentication
|
||||
|
||||
- [x] Login with JMAP server authentication
|
||||
- [x] Session management (no password storage for security)
|
||||
- [x] Username autocomplete with history
|
||||
- [x] Logout functionality
|
||||
- [x] Authentication error handling
|
||||
- [x] JMAP identities for sender address
|
||||
- [x] TOTP two-factor authentication (Stalwart-compatible)
|
||||
- [x] OAuth2/OIDC with PKCE (opt-in SSO, session persistence, RP-initiated logout)
|
||||
- [x] External IdP support via explicit issuer URL (Keycloak, Authentik, etc.)
|
||||
- [x] "Remember me" session persistence for Basic Auth (AES-256-GCM encrypted httpOnly cookie)
|
||||
|
||||
### JMAP Server Connection
|
||||
|
||||
- [x] Session establishment and keep-alive
|
||||
- [x] Connection error handling and retries
|
||||
- [x] CORS error detection with actionable user guidance
|
||||
- [x] Session URL origin rewriting (fixes Docker/reverse proxy deployments where server returns internal hostname)
|
||||
- [x] Storage quota display
|
||||
- [x] Server capability detection
|
||||
- [x] Shared folders support (multi-account access)
|
||||
|
||||
### Email Operations
|
||||
|
||||
- [x] Email fetching and display
|
||||
- [x] Full HTML email rendering
|
||||
- [x] Compose, reply, reply-all, forward
|
||||
- [x] Draft auto-save with discard confirmation
|
||||
- [x] Mark as read/unread
|
||||
- [x] Star/unstar emails
|
||||
- [x] Delete and archive
|
||||
- [x] Color tags/labels
|
||||
- [x] Full-text search
|
||||
- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries
|
||||
- [x] Attachment upload and download
|
||||
- [x] Batch operations (multi-select)
|
||||
- [x] Quick reply form
|
||||
- [x] Email threading (Gmail-style inline expansion)
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
- [x] EventSource for JMAP push notifications
|
||||
- [x] State synchronization
|
||||
- [x] Email arrival notifications
|
||||
- [x] Real-time unread counts
|
||||
- [x] Mailbox change handling
|
||||
|
||||
### User Interface
|
||||
|
||||
- [x] Three-pane layout (sidebar, list, viewer)
|
||||
- [x] Minimalist design system
|
||||
- [x] Dark and light theme support
|
||||
- [x] Custom scrollbars
|
||||
- [x] Mobile responsive design
|
||||
- [x] Keyboard shortcuts
|
||||
- [x] Drag-and-drop email organization
|
||||
- [x] Right-click context menus
|
||||
- [x] Hierarchical mailbox display
|
||||
- [x] Email list with avatars and visual hierarchy
|
||||
- [x] Expandable email headers
|
||||
- [x] External content warning banner
|
||||
- [x] SPF/DKIM/DMARC status indicators
|
||||
- [x] Loading states and skeletons
|
||||
- [x] Smooth transitions and animations
|
||||
- [x] Infinite scroll pagination
|
||||
- [x] Virtual scrolling for large email lists
|
||||
- [x] Error boundaries
|
||||
- [x] Settings page with preferences
|
||||
- [x] Navigation rail (desktop vertical icon sidebar + mobile bottom tab bar)
|
||||
- [x] Welcome banner for first-time users (one-time display, localStorage persistence)
|
||||
- [x] Confirmation dialog component with promise-based useConfirmDialog hook
|
||||
- [x] Toast notifications with undo action support and typed durations
|
||||
- [x] Inline form validation with shake animation (email composer, contact form)
|
||||
- [x] Login UX polish (error shake, discreet 2FA toggle, password visibility toggle, session expired banner)
|
||||
- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions)
|
||||
- [x] WCAG AA reduced-motion media query (global animation/transition reset)
|
||||
- [x] Safe area inset utilities for notched devices
|
||||
- [x] Screen reader live region announcements (sr-only)
|
||||
|
||||
### Internationalization
|
||||
|
||||
- [x] English language support
|
||||
- [x] French language support
|
||||
- [x] Japanese language support
|
||||
- [x] Spanish language support
|
||||
- [x] Italian language support
|
||||
- [x] German language support
|
||||
- [x] Dutch language support
|
||||
- [x] Portuguese language support
|
||||
- [x] Automatic browser language detection
|
||||
- [x] Language preference persistence
|
||||
|
||||
### Security & Accessibility
|
||||
|
||||
- [x] External content blocked by default
|
||||
- [x] HTML sanitization with DOMPurify
|
||||
- [x] User control for loading external content
|
||||
- [x] Trusted senders list for automatic image loading
|
||||
- [x] Dark mode email readability (intelligent color transformation)
|
||||
- [x] WCAG 2.0 Level AA color contrast compliance
|
||||
- [x] Newsletter unsubscribe support (RFC 2369)
|
||||
- [x] XSS attack prevention with comprehensive validation
|
||||
- [x] CSP Report-Only headers with per-request nonce
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy)
|
||||
- [x] Reusable focus trap hook (Tab cycling, Escape handling, focus restore)
|
||||
- [x] WCAG AA prefers-reduced-motion support (global animation/transition reset)
|
||||
- [x] Safe area insets for notched mobile devices
|
||||
- [x] Screen reader sr-only live region for dynamic announcements
|
||||
|
||||
### Identity Management
|
||||
|
||||
- [x] Multiple sender identities (name, email, signature)
|
||||
- [x] Sub-addressing support (user+tag@domain.com)
|
||||
- [x] Per-identity signatures
|
||||
- [x] Identity badges in email viewer and list
|
||||
- [x] Tag suggestions based on context
|
||||
- [x] Display name included in From header (recipients see name, not just email)
|
||||
- [x] Primary identity (matching login) selected by default in composer
|
||||
|
||||
### Address Book & Contacts
|
||||
|
||||
- [x] Contact store with JMAP sync and local fallback
|
||||
- [x] Contact CRUD operations (create, read, update, delete)
|
||||
- [x] Contacts list view with search/filter
|
||||
- [x] Contact details view/edit form
|
||||
- [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
|
||||
- [x] Email autocomplete from contacts
|
||||
- [x] Contacts integration in email composer (To/Cc/Bcc)
|
||||
- [x] Contact groups/lists management with JMAP members map
|
||||
- [x] vCard import/export (RFC 6350 parser/generator, duplicate detection)
|
||||
- [x] Bulk contact operations (multi-select, delete, group add, export)
|
||||
- [x] i18n support for contacts (all 8 languages)
|
||||
|
||||
### Vacation Responder
|
||||
|
||||
- [x] JMAP VacationResponse singleton management
|
||||
- [x] Settings tab with date range and message configuration
|
||||
- [x] Sidebar indicator when vacation auto-reply is active
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Calendar Integration
|
||||
|
||||
- [x] JMAP Calendar types (RFC 8984) and client methods
|
||||
- [x] Calendar capability detection (urn:ietf:params:jmap:calendars)
|
||||
- [x] Calendar store with Zustand (persist middleware)
|
||||
- [x] Month, week, day, and agenda views
|
||||
- [x] Event modal (create/edit/delete with recurrence, reminders)
|
||||
- [x] Mini-calendar sidebar with calendar visibility toggles
|
||||
- [x] Calendar settings (default view, week start, time format)
|
||||
- [x] Multi-day event spanning across all covered days
|
||||
- [x] Column-based overlap layout for concurrent events
|
||||
- [x] Locale-aware date formatting via next-intl
|
||||
- [x] First day of week and time format settings wired to views
|
||||
- [x] Push notification handling for calendar state changes
|
||||
- [x] Calendar page capability check (redirect if unsupported)
|
||||
- [x] Error handling with toast feedback on event CRUD
|
||||
- [x] Timezone auto-detection on event creation
|
||||
- [x] Input validation, color sanitization, focus trap
|
||||
- [x] ARIA grid roles and event card accessible labels
|
||||
- [x] Mobile touch targets (44px minimum)
|
||||
- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event)
|
||||
- [x] i18n support with ICU pluralization (all 8 languages)
|
||||
- [x] Drag-and-drop event rescheduling (week/day time snap, month date move)
|
||||
- [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create
|
||||
- [x] Event notifications with client-side alert evaluation and toast display
|
||||
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
|
||||
- [x] Configurable notification settings (enable/disable, sound toggle)
|
||||
- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete)
|
||||
- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display)
|
||||
- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete)
|
||||
- [x] Click-drag to create events (pointer-based time range selection, 15-min snap, visual overlay)
|
||||
- [x] Event resize by dragging bottom edge handle (15-min snap, optimistic JMAP update)
|
||||
- [x] Recurring event edit/delete scope dialog (this event / this and following / all events)
|
||||
- [x] Double-click quick event creation (inline title input, PT1H default)
|
||||
- [x] Event duplication button in modal (clones event +1 day, opens for editing)
|
||||
|
||||
### Email Filters
|
||||
|
||||
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
||||
- [x] Visual rule builder (conditions: From/To/Cc/Subject/Header/Size/Body, actions: Move/Copy/Forward/Mark read/Star/Label/Discard/Reject/Keep/Stop)
|
||||
- [x] Raw Sieve script editor with syntax validation
|
||||
- [x] Sieve generator and parser with JSON metadata round-trip
|
||||
- [x] Filter store with CRUD, reorder, toggle, auto-save with rollback
|
||||
- [x] Opaque script detection with reset to visual builder option
|
||||
- [x] Focus trap accessibility in modals
|
||||
- [x] Toast validation feedback for empty rules
|
||||
- [x] Push notification handling for SieveScript state changes
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Email Templates
|
||||
|
||||
- [x] Reusable email templates with local storage persistence
|
||||
- [x] Category organization (General, Business, Personal, Support, Follow-up, custom)
|
||||
- [x] Dynamic placeholder variables with auto-fill from composer context
|
||||
- [x] Template manager modal (create, edit, duplicate, delete)
|
||||
- [x] Template picker in composer toolbar with search and category filter
|
||||
- [x] Custom placeholder prompt on template insertion
|
||||
- [x] Settings tab for template management
|
||||
- [x] Keyboard shortcut (Ctrl+Shift+T to insert template)
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Email Display
|
||||
|
||||
- [x] Proper email layout without horizontal scroll or clipping
|
||||
- [x] Blocked image container collapsing (no empty spaces in newsletters)
|
||||
|
||||
### Testing
|
||||
|
||||
- [x] Unit tests for validation utilities (57 tests)
|
||||
- [x] Unit tests for email sanitization (27 tests)
|
||||
- [x] Unit tests for color transformation (40 tests)
|
||||
- [x] Unit tests for contact store (56 tests)
|
||||
- [x] Unit tests for JMAP contact client (41 tests)
|
||||
- [x] Unit tests for vCard parser (18 tests)
|
||||
- [x] Unit tests for thread utilities (20 tests)
|
||||
- [x] Unit tests for email headers (39 tests)
|
||||
- [x] Component tests (contacts, UI components — 41 tests)
|
||||
- [x] JMAP client method tests (identity: 20, contacts: 41)
|
||||
- [x] Unit tests for Sieve generator (50 tests)
|
||||
- [x] Unit tests for Sieve parser (14 tests)
|
||||
- [x] Unit tests for calendar alerts (36 tests)
|
||||
- [x] Unit tests for calendar notification store (8 tests)
|
||||
- [x] Unit tests for calendar invitation parsing (25 tests)
|
||||
- [x] Unit tests for calendar participants (26 tests)
|
||||
- [x] Unit tests for template utilities (48 tests)
|
||||
- [x] Unit tests for OAuth PKCE and discovery (14 tests)
|
||||
- [x] XSS attack vector testing
|
||||
- [x] Playwright E2E framework setup
|
||||
|
||||
### Deployment
|
||||
|
||||
- [x] Runtime environment variables (Docker-friendly configuration)
|
||||
- [x] Health check endpoint
|
||||
- [x] Docker support (multi-stage build, docker-compose, standalone output)
|
||||
- [x] Structured server-side logger (text/JSON format, configurable level)
|
||||
- [x] Pre-built Docker image on [Docker Hub](https://hub.docker.com/r/bulwarkmail/webmail) and [GHCR](https://ghcr.io/bulwarkmail/webmail) with multi-arch support (amd64/arm64)
|
||||
- [x] GitHub Actions CI/CD for automated image publishing on releases
|
||||
- [x] CVE remediation: remove npm from production image, upgrade Alpine packages
|
||||
- [x] Server-side update check (logs newer version availability on startup)
|
||||
|
||||
## Planned Features
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- [ ] Free/busy queries (Principal/getAvailability)
|
||||
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
||||
- [ ] Email encryption (PGP/GPG)
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
- [ ] Email content caching
|
||||
- [ ] Bundle size optimization
|
||||
- [ ] Service worker for offline support
|
||||
- [ ] Lazy loading for attachments
|
||||
|
||||
### Testing (Remaining)
|
||||
|
||||
- [ ] E2E tests with real JMAP server
|
||||
- [ ] Accessibility testing
|
||||
- [ ] Performance testing
|
||||
|
||||
### Deployment
|
||||
|
||||
- [ ] Production build optimizations
|
||||
- [ ] Monitoring and logging
|
||||
|
||||
### Security Enhancements
|
||||
|
||||
- [ ] Rate limiting
|
||||
|
||||
## Known Issues
|
||||
|
||||
- [ ] Next.js workspace root warning (cosmetic)
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to help implement a feature? Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide!
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
format, parseISO,
|
||||
} from "date-fns";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { isCalendarViewMode } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -30,6 +31,9 @@ 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";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
@@ -48,6 +52,7 @@ export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const isMobile = useIsMobile();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
@@ -60,6 +65,7 @@ export default function CalendarPage() {
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||
const { identities } = useIdentityStore();
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
|
||||
const currentUserEmails = useMemo(() =>
|
||||
identities.map(id => id.email).filter(Boolean),
|
||||
@@ -129,7 +135,7 @@ export default function CalendarPage() {
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const d = selectedDate;
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month": {
|
||||
const ms = startOfMonth(d);
|
||||
const me = endOfMonth(d);
|
||||
@@ -156,17 +162,17 @@ export default function CalendarPage() {
|
||||
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
}
|
||||
}, [selectedDate, viewMode, firstDayOfWeek]);
|
||||
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && calendars.length > 0) {
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
let next: Date;
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month": next = subMonths(selectedDate, 1); break;
|
||||
case "week": next = subWeeks(selectedDate, 1); break;
|
||||
case "day": next = subDays(selectedDate, 1); break;
|
||||
@@ -174,11 +180,11 @@ export default function CalendarPage() {
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
}, [viewMode, selectedDate, setSelectedDate]);
|
||||
}, [normalizedViewMode, selectedDate, setSelectedDate]);
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
let next: Date;
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month": next = addMonths(selectedDate, 1); break;
|
||||
case "week": next = addWeeks(selectedDate, 1); break;
|
||||
case "day": next = addDays(selectedDate, 1); break;
|
||||
@@ -186,7 +192,7 @@ export default function CalendarPage() {
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
}, [viewMode, selectedDate, setSelectedDate]);
|
||||
}, [normalizedViewMode, selectedDate, setSelectedDate]);
|
||||
|
||||
const goToToday = useCallback(() => {
|
||||
setSelectedDate(new Date());
|
||||
@@ -218,10 +224,10 @@ export default function CalendarPage() {
|
||||
setSelectedDate(date);
|
||||
setMiniMonth(date);
|
||||
// On mobile month view, tapping a date switches to day view
|
||||
if (isMobile && viewMode === "month") {
|
||||
if (isMobile && normalizedViewMode === "month") {
|
||||
setViewMode("day");
|
||||
}
|
||||
}, [setSelectedDate, isMobile, viewMode, setViewMode]);
|
||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||
|
||||
const handleMiniMonthChange = useCallback((date: Date) => {
|
||||
setMiniMonth(date);
|
||||
@@ -621,7 +627,7 @@ export default function CalendarPage() {
|
||||
}
|
||||
|
||||
const viewContent = (() => {
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month":
|
||||
return (
|
||||
<CalendarMonthView
|
||||
@@ -703,12 +709,20 @@ export default function CalendarPage() {
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Sidebar - full height */}
|
||||
{!isMobile && (
|
||||
{!isMobile && !inlineApp && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -748,10 +762,11 @@ export default function CalendarPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!inlineApp && (
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<CalendarToolbar
|
||||
selectedDate={selectedDate}
|
||||
viewMode={viewMode}
|
||||
viewMode={normalizedViewMode}
|
||||
onPrev={navigatePrev}
|
||||
onNext={navigateNext}
|
||||
onToday={goToToday}
|
||||
@@ -802,12 +817,19 @@ export default function CalendarPage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
@@ -859,6 +881,7 @@ export default function CalendarPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
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 type { ContactCard } from "@/lib/jmap/types";
|
||||
@@ -38,6 +41,7 @@ export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("contacts");
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
@@ -476,12 +480,19 @@ export default function ContactsPage() {
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{showListPanel && (
|
||||
<>
|
||||
{/* Panel 1: Categories sidebar */}
|
||||
@@ -582,10 +593,17 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,9 @@ import { useFileStore } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
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 { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
@@ -24,6 +27,7 @@ export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
@@ -354,12 +358,19 @@ export default function FilesPage() {
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{folderLayout !== "sidebar" && (
|
||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||
@@ -433,7 +444,13 @@ export default function FilesPage() {
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -457,6 +474,7 @@ export default function FilesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
|
||||
const APP_VERSION = "1.2.4";
|
||||
const APP_VERSION = "1.4.0";
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
|
||||
+71
-15
@@ -35,22 +35,28 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const { appName } = useConfig();
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
|
||||
@@ -224,7 +230,7 @@ export default function Home() {
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = tCommon('app_title');
|
||||
let title = appName;
|
||||
|
||||
if (showComposer) {
|
||||
// Composing email
|
||||
@@ -234,11 +240,11 @@ export default function Home() {
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message');
|
||||
title = `${modeText} - ${tCommon('app_title')}`;
|
||||
title = `${modeText} - ${appName}`;
|
||||
} else if (selectedEmail) {
|
||||
// Reading email
|
||||
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
||||
title = `${subject} - ${tCommon('app_title')}`;
|
||||
title = `${subject} - ${appName}`;
|
||||
} else if (selectedMailbox && mailboxes.length > 0) {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
@@ -246,13 +252,13 @@ export default function Home() {
|
||||
const mailboxName = mailbox.name;
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - ${tCommon('app_title')}`
|
||||
: `${mailboxName} - ${tCommon('app_title')}`;
|
||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||
: `${mailboxName} - ${appName}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, tCommon]);
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
@@ -516,12 +522,46 @@ export default function Home() {
|
||||
|
||||
// Find archive mailbox
|
||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
if (archiveMailbox) {
|
||||
try {
|
||||
if (!archiveMailbox) return;
|
||||
|
||||
const { archiveMode } = useSettingsStore.getState();
|
||||
|
||||
try {
|
||||
if (archiveMode === 'single') {
|
||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
} else {
|
||||
// Determine year/month from the email's received date
|
||||
const emailDate = new Date(selectedEmail.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(
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
yearMailbox = await client.createMailbox(year, archiveId);
|
||||
await fetchMailboxes(client);
|
||||
}
|
||||
|
||||
if (archiveMode === 'year') {
|
||||
await moveToMailbox(client, selectedEmail.id, yearMailbox.id);
|
||||
} else {
|
||||
// archiveMode === 'month' — find or create month subfolder under year
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = mailboxes.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
monthMailbox = await client.createMailbox(month, yearId);
|
||||
await fetchMailboxes(client);
|
||||
}
|
||||
await moveToMailbox(client, selectedEmail.id, monthMailbox.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -971,12 +1011,20 @@ export default function Home() {
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={handleLogout}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
|
||||
{(isMobile || isTablet) && sidebarOpen && (
|
||||
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
@@ -993,7 +1041,8 @@ export default function Home() {
|
||||
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
|
||||
!sidebarOpen && "max-lg:-translate-x-full",
|
||||
// Desktop: normal flow
|
||||
"lg:relative lg:translate-x-0"
|
||||
"lg:relative lg:translate-x-0",
|
||||
inlineApp && "hidden"
|
||||
)}
|
||||
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
|
||||
>
|
||||
@@ -1019,7 +1068,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
|
||||
{!isMobile && !isTablet && !sidebarCollapsed && (
|
||||
{!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
|
||||
@@ -1029,7 +1078,7 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 h-full">
|
||||
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
|
||||
<div
|
||||
@@ -1516,7 +1565,13 @@ export default function Home() {
|
||||
|
||||
{/* Bottom Navigation - mobile and tablet */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1539,6 +1594,7 @@ export default function Home() {
|
||||
{/* Screen reader live region for dynamic status announcements */}
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
HardDrive,
|
||||
Wrench,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -40,15 +42,20 @@ import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
|
||||
interface TabDef {
|
||||
@@ -64,6 +71,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
account: User,
|
||||
security: Shield,
|
||||
identities: UserPen,
|
||||
encryption: KeyRound,
|
||||
vacation: PalmtreeIcon,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
@@ -72,6 +80,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
files: HardDrive,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
advanced: Wrench,
|
||||
};
|
||||
|
||||
@@ -82,6 +91,7 @@ export default function SettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
@@ -131,6 +141,7 @@ export default function SettingsPage() {
|
||||
{ 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' },
|
||||
{ id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' },
|
||||
...(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 }] : []),
|
||||
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
|
||||
@@ -139,6 +150,7 @@ export default function SettingsPage() {
|
||||
...(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 }] : []),
|
||||
{ id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' },
|
||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
||||
];
|
||||
|
||||
@@ -168,6 +180,7 @@ export default function SettingsPage() {
|
||||
{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 />}
|
||||
@@ -176,6 +189,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
{activeTab === 'keywords' && <KeywordSettings />}
|
||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</>
|
||||
);
|
||||
@@ -207,7 +221,14 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,7 +296,14 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -290,9 +318,18 @@ export default function SettingsPage() {
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
{/* Settings Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -380,6 +417,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ export async function GET() {
|
||||
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
|
||||
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
|
||||
devMode: process.env.DEV_MOCK_JMAP === 'true',
|
||||
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg',
|
||||
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '',
|
||||
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '',
|
||||
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
|
||||
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
|
||||
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
|
||||
|
||||
+9
-4
@@ -14,10 +14,15 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Bulwark Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const faviconUrl = process.env.FAVICON_URL;
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
|
||||
@@ -33,6 +34,7 @@ export function CalendarSidebarPanel({
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||
@@ -169,7 +171,7 @@ export function CalendarSidebarPanel({
|
||||
</button>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Users } from "lucide-react";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
@@ -68,11 +69,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
const startDate = parseISO(event.start);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
const timeString = `${format(startDate, "HH:mm")} – ${format(endTime, "HH:mm")}`;
|
||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface EventModalProps {
|
||||
event?: CalendarEvent | null;
|
||||
@@ -108,6 +109,8 @@ export function EventModal({
|
||||
isMobile = false,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
const isEdit = !!event;
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||
|
||||
@@ -452,7 +455,7 @@ export function EventModal({
|
||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,7 +579,7 @@ export function EventModal({
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { format, parseISO } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
@@ -28,6 +29,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const tCommon = useTranslations("common");
|
||||
const tForm = useTranslations("calendar.form");
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
|
||||
const [step, setStep] = useState<ImportStep>("select");
|
||||
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
|
||||
@@ -194,9 +196,10 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
if (!event.start) return "";
|
||||
try {
|
||||
const date = parseISO(event.start);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
return event.showWithoutTime
|
||||
? format(date, "MMM d, yyyy")
|
||||
: format(date, "MMM d, yyyy HH:mm");
|
||||
: format(date, `MMM d, yyyy ${timeFmt}`);
|
||||
} catch {
|
||||
return event.start;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
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";
|
||||
|
||||
interface ContactDetailProps {
|
||||
@@ -56,6 +60,50 @@ function formatDate(dateInput: string | Record<string, unknown>): string {
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!contact) return;
|
||||
let cancelled = false;
|
||||
const parseCerts = async () => {
|
||||
const results = new Map<number, CertificateInfo>();
|
||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
||||
const key = cryptoKeys[i];
|
||||
if (typeof key.uri !== 'string') continue;
|
||||
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);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
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;
|
||||
const cert = parseCertificatePemOrDer(derBytes);
|
||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
if (!cancelled) results.set(i, info);
|
||||
} catch { /* skip unparseable keys */ }
|
||||
}
|
||||
if (!cancelled) setParsedCerts(results);
|
||||
};
|
||||
if (cryptoKeys.length > 0) {
|
||||
parseCerts();
|
||||
} else {
|
||||
setParsedCerts(new Map());
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
@@ -79,7 +127,31 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||
const cryptoKeys = contact.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
|
||||
const handleImportContactCert = async (keyIndex: number) => {
|
||||
const key = cryptoKeys[keyIndex];
|
||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
||||
toast.success(t("detail.cert_imported"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
||||
}
|
||||
};
|
||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||
@@ -331,17 +403,63 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
|
||||
{cryptoKeys.map((key, i) => (
|
||||
<div key={i} className="text-sm break-all">
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{cryptoKeys.map((key, i) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
||||
<p>
|
||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
||||
</p>
|
||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
||||
{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)}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
)}
|
||||
{alreadyImported && (
|
||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm break-all">
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore();
|
||||
|
||||
const [state, setState] = useState<BannerState>('loading');
|
||||
@@ -544,6 +545,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
dtStart: parsedEvent.start || undefined,
|
||||
dtEnd: summary?.end || undefined,
|
||||
timeZone: parsedEvent.timeZone || undefined,
|
||||
isAllDay: parsedEvent.showWithoutTime || false,
|
||||
sequence: parsedEvent.sequence,
|
||||
status: imipStatus,
|
||||
});
|
||||
@@ -659,16 +661,26 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
}
|
||||
};
|
||||
|
||||
const isAllDayEvent = parsedEvent?.showWithoutTime ?? false;
|
||||
|
||||
const formatDateTime = (dateStr: string | null) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
if (isAllDayEvent) {
|
||||
return format.dateTime(date, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
return format.dateTime(date, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -5,13 +5,20 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
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 } from "lucide-react";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
@@ -80,6 +87,7 @@ export function EmailComposer({
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -118,7 +126,7 @@ export function EmailComposer({
|
||||
const prefix = initialDraftText || "";
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
@@ -169,6 +177,11 @@ export function EmailComposer({
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
const [smimeSign_, setSmimeSign] = useState(false);
|
||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -187,6 +200,33 @@ export function EmailComposer({
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||
const smimeStore = useSmimeStore();
|
||||
|
||||
// Determine S/MIME availability for the selected identity
|
||||
const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id;
|
||||
const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined;
|
||||
const canSmimeSign = !!smimeKeyRecord;
|
||||
const canSmimeEncrypt = (() => {
|
||||
if (!smimeKeyRecord) return false;
|
||||
const toAddrs = to.split(',').map(e => e.trim()).filter(Boolean);
|
||||
const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean);
|
||||
const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean);
|
||||
const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs];
|
||||
if (allRecipients.length === 0) return false;
|
||||
const { missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
return missing.length === 0;
|
||||
})();
|
||||
|
||||
// Initialize S/MIME defaults from store when identity changes
|
||||
useEffect(() => {
|
||||
if (currentSmimeIdentityId) {
|
||||
setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign);
|
||||
}
|
||||
setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt);
|
||||
// Only run when identity changes, not on every recipient edit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSmimeIdentityId]);
|
||||
|
||||
// Keep a ref to current state for the unmount save
|
||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
|
||||
@@ -624,14 +664,25 @@ export function EmailComposer({
|
||||
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
|
||||
}
|
||||
|
||||
// Build HTML body when replying/forwarding with original HTML content
|
||||
// Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (currentIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (currentIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Build HTML body
|
||||
let finalHtmlBody: string | undefined;
|
||||
const signatureHtml = buildSignatureHtml();
|
||||
|
||||
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
// Reply/forward with original HTML content
|
||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
const signatureHtml = currentIdentity?.textSignature
|
||||
? `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`
|
||||
: '';
|
||||
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : '';
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
||||
const fromAddr = replyTo.from?.[0];
|
||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
||||
const quoteHeader = mode === 'forward'
|
||||
@@ -639,21 +690,123 @@ export function EmailComposer({
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
|
||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
|
||||
} else if (signatureHtml) {
|
||||
// New compose or plain-text reply — include HTML body with signature
|
||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}`;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSend?.({
|
||||
to: toAddresses,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
subject,
|
||||
body: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
identityId: currentIdentity?.id,
|
||||
});
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||
// 1. Resolve S/MIME key
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
const passphrase = await new Promise<string>((resolve, reject) => {
|
||||
setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject });
|
||||
});
|
||||
try {
|
||||
await smimeStore.unlockKey(smimeKeyRecord.id, passphrase);
|
||||
} finally {
|
||||
setSmimePassphrasePrompt(null);
|
||||
setSmimePassphraseInput('');
|
||||
setSmimePassphraseError('');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve attachments as ArrayBuffers
|
||||
const mimeAttachments: MimeAttachment[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.error || att.uploading) continue;
|
||||
let content: ArrayBuffer;
|
||||
if (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);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
mimeAttachments.push({
|
||||
filename: att.file.name,
|
||||
contentType: att.file.type || 'application/octet-stream',
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
const mimeBytes = buildMimeMessage({
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses.map(e => ({ email: e })),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
||||
subject,
|
||||
textBody: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||
});
|
||||
|
||||
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' });
|
||||
|
||||
const smimeHeaders = {
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses.map(e => ({ email: e })),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||
subject,
|
||||
};
|
||||
|
||||
// 5. Sign if enabled
|
||||
if (smimeSign_ && smimeKeyRecord) {
|
||||
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id);
|
||||
if (!privateKey) throw new Error('S/MIME key is not unlocked');
|
||||
const cmsBlob = await smimeSign(
|
||||
mimeBytes,
|
||||
privateKey,
|
||||
smimeKeyRecord.certificate,
|
||||
smimeKeyRecord.certificateChain || [],
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' });
|
||||
}
|
||||
|
||||
// 6. Encrypt if enabled
|
||||
if (smimeEncrypt_ && smimeKeyRecord) {
|
||||
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses];
|
||||
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
|
||||
}
|
||||
const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer);
|
||||
const payloadBytes = new Uint8Array(await payload.arrayBuffer());
|
||||
const cmsBlob = await smimeEncrypt(
|
||||
payloadBytes,
|
||||
recipientCertsDer,
|
||||
smimeKeyRecord.certificate,
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' });
|
||||
}
|
||||
|
||||
// 7. Send via raw email path
|
||||
await sendRawEmail(client, payload, currentIdentity.id);
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
await onSend?.({
|
||||
to: toAddresses,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
subject,
|
||||
body: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
identityId: currentIdentity?.id,
|
||||
});
|
||||
}
|
||||
|
||||
setTo("");
|
||||
setCc("");
|
||||
@@ -956,7 +1109,7 @@ export function EmailComposer({
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{mode === 'forward'
|
||||
? `---------- ${t('prefix.forward')} ----------`
|
||||
: `${replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
|
||||
: `${replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
@@ -1059,6 +1212,32 @@ export function EmailComposer({
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* S/MIME toggles */}
|
||||
{canSmimeSign && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSmimeSign(v => !v)}
|
||||
className={cn("h-9 w-9", smimeSign_ && "bg-primary/10 text-primary")}
|
||||
title={smimeSign_ ? t('smime_sign_on') : t('smime_sign_off')}
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSmimeEncrypt(v => !v)}
|
||||
disabled={!canSmimeEncrypt}
|
||||
className={cn("h-9 w-9", smimeEncrypt_ && "bg-primary/10 text-primary")}
|
||||
title={smimeEncrypt_ ? t('smime_encrypt_on') : canSmimeEncrypt ? t('smime_encrypt_off') : t('smime_encrypt_unavailable')}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
@@ -1117,6 +1296,60 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S/MIME passphrase prompt */}
|
||||
{smimePassphrasePrompt && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('smime_unlock_title')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('smime_unlock_message')}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={smimePassphraseInput}
|
||||
onChange={(e) => {
|
||||
setSmimePassphraseInput(e.target.value);
|
||||
setSmimePassphraseError('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && smimePassphraseInput) {
|
||||
smimePassphrasePrompt.resolve(smimePassphraseInput);
|
||||
}
|
||||
}}
|
||||
placeholder={t('smime_passphrase_placeholder')}
|
||||
className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{smimePassphraseError && (
|
||||
<p className="mt-1 text-xs text-red-500">{smimePassphraseError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={() => {
|
||||
smimePassphrasePrompt.reject();
|
||||
setSmimePassphrasePrompt(null);
|
||||
setSmimePassphraseInput('');
|
||||
setSmimePassphraseError('');
|
||||
}}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!smimePassphraseInput}
|
||||
onClick={() => smimePassphrasePrompt.resolve(smimePassphraseInput)}
|
||||
>
|
||||
{t('smime_unlock_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCloseDialog && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
|
||||
+1186
-62
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SmimeStatus } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeStatusBannerProps {
|
||||
status: SmimeStatus;
|
||||
onUnlockKey?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
||||
const t = useTranslations('smime');
|
||||
|
||||
const items: Array<{
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
variant: 'success' | 'warning' | 'error' | 'info';
|
||||
}> = [];
|
||||
|
||||
// Encryption status
|
||||
if (status.isEncrypted) {
|
||||
if (status.decryptionError) {
|
||||
if (status.decryptionError === 'locked') {
|
||||
items.push({
|
||||
icon: <Lock className="w-4 h-4" />,
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-4 h-4" />,
|
||||
text: t('status_encrypted_failed'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({
|
||||
icon: <LockOpen className="w-4 h-4" />,
|
||||
text: t('status_encrypted_ok'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
if (status.signatureValid === true) {
|
||||
if (status.signerEmailMatch === false) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldCheck className="w-4 h-4" />,
|
||||
text: t('status_signed_valid'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
} else if (status.signatureValid === false) {
|
||||
items.push({
|
||||
icon: <ShieldAlert className="w-4 h-4" />,
|
||||
text: status.signatureError || t('status_signed_invalid'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported S/MIME
|
||||
if (status.unsupportedReason) {
|
||||
items.push({
|
||||
icon: <Info className="w-4 h-4" />,
|
||||
text: t('status_unsupported'),
|
||||
variant: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const variantStyles = {
|
||||
success: 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
||||
error: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',
|
||||
info: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5 py-1", className)}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border",
|
||||
variantStyles[item.variant],
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="flex-1">{item.text}</span>
|
||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
||||
<button
|
||||
onClick={onUnlockKey}
|
||||
className="text-xs font-medium underline hover:no-underline"
|
||||
>
|
||||
{t('unlock_key')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,7 @@ function EmailCard({
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -363,7 +364,7 @@ function EmailCard({
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
if (resolvedTheme === 'dark') {
|
||||
if (resolvedTheme === 'dark' && !emailAlwaysLightMode) {
|
||||
if (htmlNode.style) {
|
||||
const originalStyles = htmlNode.style.cssText;
|
||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||
@@ -403,7 +404,6 @@ function EmailCard({
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
||||
return { html: htmlEscaped, isHtml: false };
|
||||
}
|
||||
@@ -411,11 +411,15 @@ function EmailCard({
|
||||
|
||||
// Fallback to preview
|
||||
if (email.preview) {
|
||||
return { html: email.preview.replace(/\n/g, '<br>'), isHtml: false };
|
||||
const previewHtml = email.preview
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return { html: previewHtml, isHtml: false };
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal, resolvedTheme, cidBlobUrls]);
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
@@ -513,12 +517,14 @@ function EmailCard({
|
||||
<div style={{ padding: 'var(--density-card-p)' }}>
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none dark:prose-invert",
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
const tNotif = useTranslations('notifications');
|
||||
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
|
||||
const identities = useIdentityStore((state) => state.identities);
|
||||
const syncIdentities = useSyncIdentities();
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -46,6 +46,25 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
// Re-fetch all identities from server and update stores
|
||||
const refreshIdentities = useCallback(async () => {
|
||||
if (!client) return;
|
||||
try {
|
||||
const serverIdentities = await client.getIdentities();
|
||||
const username = useAuthStore.getState().username;
|
||||
const sorted = [...serverIdentities].sort((a, b) => {
|
||||
const aMatch = a.email === username ? -1 : 0;
|
||||
const bMatch = b.email === username ? -1 : 0;
|
||||
return aMatch - bMatch;
|
||||
});
|
||||
useIdentityStore.getState().setIdentities(sorted);
|
||||
syncIdentities();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to refresh identities';
|
||||
toast.error(message);
|
||||
}
|
||||
}, [client, syncIdentities]);
|
||||
|
||||
// Focus trap with Escape handling
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
@@ -60,9 +79,10 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
// Close on click outside
|
||||
// Close on click outside (but not when ConfirmDialog is open)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (confirmDialogProps.isOpen) return;
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
@@ -72,13 +92,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen, onClose, modalRef]);
|
||||
}, [isOpen, onClose, modalRef, confirmDialogProps.isOpen]);
|
||||
|
||||
const handleCreate = useCallback(async (data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const newIdentity = await client.createIdentity(
|
||||
await client.createIdentity(
|
||||
data.name,
|
||||
data.email,
|
||||
data.replyTo,
|
||||
@@ -87,8 +107,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
data.htmlSignature
|
||||
);
|
||||
|
||||
addIdentity(newIdentity);
|
||||
syncIdentities();
|
||||
await refreshIdentities();
|
||||
setIsCreating(false);
|
||||
toast.success(tNotif('identity_created'));
|
||||
} catch (error) {
|
||||
@@ -96,7 +115,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
toast.error(tNotif('identity_create_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, addIdentity, t, tNotif]);
|
||||
}, [client, refreshIdentities, t, tNotif]);
|
||||
|
||||
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
@@ -110,8 +129,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
htmlSignature: data.htmlSignature,
|
||||
});
|
||||
|
||||
updateIdentityLocal(identity.id, data);
|
||||
syncIdentities();
|
||||
await refreshIdentities();
|
||||
setEditingId(null);
|
||||
toast.success(tNotif('identity_updated'));
|
||||
} catch (error) {
|
||||
@@ -119,7 +137,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
toast.error(tNotif('identity_update_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, updateIdentityLocal, t, tNotif]);
|
||||
}, [client, refreshIdentities, t, tNotif]);
|
||||
|
||||
const handleDelete = useCallback(async (identity: Identity) => {
|
||||
if (!client) return;
|
||||
@@ -140,8 +158,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
try {
|
||||
await client.deleteIdentity(identity.id);
|
||||
removeIdentity(identity.id);
|
||||
syncIdentities();
|
||||
await refreshIdentities();
|
||||
toast.success(tNotif('identity_deleted'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
@@ -149,7 +166,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, [client, removeIdentity, t, tNotif, confirmDialog]);
|
||||
}, [client, refreshIdentities, t, tNotif, confirmDialog]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// Curated list of commonly useful icons, organized by category
|
||||
const POPULAR_ICONS = [
|
||||
// Communication
|
||||
'Globe', 'Rss', 'Radio', 'Podcast', 'MessageCircle', 'MessageSquare', 'MessagesSquare',
|
||||
'Phone', 'Video', 'Webcam', 'Headphones', 'Mic',
|
||||
// Productivity
|
||||
'FileText', 'FileSpreadsheet', 'Notebook', 'BookOpen', 'ClipboardList',
|
||||
'ListTodo', 'CheckSquare', 'SquareKanban', 'Kanban', 'Trello',
|
||||
'PenLine', 'Pencil', 'Edit', 'NotebookPen',
|
||||
// Dev / Tech
|
||||
'Code', 'Terminal', 'Braces', 'Bug', 'Database', 'Server', 'Cpu',
|
||||
'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet',
|
||||
'Wifi', 'Cloud', 'CloudDownload', 'CloudUpload',
|
||||
// Social / People
|
||||
'Users', 'UserPlus', 'UserCircle', 'Contact', 'PersonStanding',
|
||||
'Heart', 'ThumbsUp', 'Star', 'Award', 'Trophy', 'Crown',
|
||||
// Media
|
||||
'Image', 'Camera', 'Film', 'Music', 'Play', 'Tv', 'Youtube', 'Clapperboard',
|
||||
'Palette', 'Paintbrush', 'Brush',
|
||||
// Navigation / Location
|
||||
'Map', 'MapPin', 'Navigation', 'Compass', 'Home', 'Building', 'Building2',
|
||||
'Landmark', 'Store', 'Warehouse',
|
||||
// Finance
|
||||
'DollarSign', 'Euro', 'CreditCard', 'Wallet', 'Receipt', 'PiggyBank',
|
||||
'TrendingUp', 'BarChart', 'BarChart3', 'LineChart', 'PieChart',
|
||||
// Security
|
||||
'Shield', 'ShieldCheck', 'Lock', 'Unlock', 'Key', 'Fingerprint', 'Eye',
|
||||
// Science / Health
|
||||
'Beaker', 'Atom', 'Dna', 'Microscope', 'Stethoscope', 'HeartPulse', 'Pill',
|
||||
'Syringe', 'Thermometer',
|
||||
// Nature
|
||||
'Sun', 'Moon', 'CloudSun', 'Snowflake', 'Zap', 'Flame',
|
||||
'TreePine', 'Flower', 'Leaf', 'Mountain', 'Waves',
|
||||
// Tools
|
||||
'Wrench', 'Hammer', 'Scissors', 'Ruler', 'Magnet',
|
||||
'Package', 'Gift', 'Box', 'Archive',
|
||||
// Transport
|
||||
'Car', 'Bike', 'Bus', 'Train', 'Plane', 'Ship', 'Rocket',
|
||||
// Food
|
||||
'Coffee', 'Wine', 'Beer', 'Pizza', 'Apple', 'Cake', 'CookingPot',
|
||||
// Misc
|
||||
'Gamepad2', 'Dice5', 'Puzzle', 'Sparkles', 'Wand2', 'Bot', 'BrainCircuit',
|
||||
'Lightbulb', 'Bookmark', 'Flag', 'Bell', 'Clock', 'Timer',
|
||||
'Link', 'ExternalLink', 'QrCode', 'Scan', 'LayoutGrid', 'Layers',
|
||||
'Aperture', 'CircleDot', 'Target', 'Crosshair',
|
||||
];
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string;
|
||||
onChange: (iconName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconPicker({ value, onChange, className }: IconPickerProps) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get all available icon names
|
||||
const allIconNames = useMemo(() => {
|
||||
return Object.keys(lucideIcons).filter(
|
||||
k => /^[A-Z]/.test(k) && k !== 'createLucideIcon' && k !== 'Icon'
|
||||
).sort();
|
||||
}, []);
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const source = showAll ? allIconNames : POPULAR_ICONS.filter(name => name in lucideIcons);
|
||||
if (!search.trim()) return source;
|
||||
const q = search.toLowerCase();
|
||||
return source.filter(name => name.toLowerCase().includes(q));
|
||||
}, [search, showAll, allIconNames]);
|
||||
|
||||
const renderIcon = useCallback((name: string) => {
|
||||
const IconComponent = lucideIcons[name as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent className="w-5 h-5" />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_icons')}
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded-md border transition-colors whitespace-nowrap',
|
||||
showAll
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-muted text-muted-foreground border-border hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{showAll ? t('show_popular') : t('show_all')}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-8 gap-1 max-h-[200px] overflow-y-auto p-1 border rounded-md bg-muted/30"
|
||||
>
|
||||
{filteredIcons.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onChange(name)}
|
||||
title={name}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-md transition-colors',
|
||||
value === name
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{renderIcon(name)}
|
||||
</button>
|
||||
))}
|
||||
{filteredIcons.length === 0 && (
|
||||
<p className="col-span-8 py-4 text-center text-xs text-muted-foreground">
|
||||
{t('no_icons_found')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
|
||||
|
||||
interface InlineAppViewProps {
|
||||
apps: InlineAppState[];
|
||||
activeAppId: string;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
|
||||
const activeApp = apps.find((a) => a.id === activeAppId);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-background', className)}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
|
||||
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Iframes - active one visible, rest hidden but alive */}
|
||||
<div className="flex-1 relative">
|
||||
{apps.map((app) => (
|
||||
<iframe
|
||||
key={app.id}
|
||||
src={app.url}
|
||||
title={app.name}
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full border-0',
|
||||
app.id !== activeAppId && 'hidden'
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard } from "lucide-react";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { usePathname, Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
@@ -27,6 +29,10 @@ interface NavigationRailProps {
|
||||
isPushConnected?: boolean;
|
||||
onLogout?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onManageApps?: () => void;
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
}
|
||||
|
||||
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
|
||||
@@ -138,12 +144,17 @@ export function NavigationRail({
|
||||
isPushConnected,
|
||||
onLogout,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -151,12 +162,14 @@ export function NavigationRail({
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
||||
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
const getIsActive = (href: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (href === "/") {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
@@ -177,6 +190,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={item.id}
|
||||
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]",
|
||||
"transition-colors duration-150",
|
||||
@@ -201,6 +215,52 @@ export function NavigationRail({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === 'tab') {
|
||||
window.open(app.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
{AppIcon ? <AppIcon className="w-5 h-5" /> : null}
|
||||
{isActive && (
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -230,6 +290,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
@@ -257,13 +318,90 @@ export function NavigationRail({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t",
|
||||
collapsed ? "w-8 mx-auto my-1 pt-1" : "mx-2 my-0.5 pt-0.5"
|
||||
)}
|
||||
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
|
||||
/>
|
||||
)}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === 'tab') {
|
||||
window.open(app.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
? "justify-center w-10 h-10"
|
||||
: "px-2.5 text-sm",
|
||||
"max-lg:min-h-[44px]",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={collapsed ? app.name : undefined}
|
||||
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
>
|
||||
{AppIcon ? <AppIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} /> : null}
|
||||
{!collapsed && <span className="truncate">{app.name}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
? "justify-center w-10 h-10"
|
||||
: "px-2.5 text-sm",
|
||||
"max-lg:min-h-[44px]",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={collapsed ? t("add_app") : undefined}
|
||||
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
>
|
||||
<Plus className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
{!collapsed && <span className="truncate">{t("add_app")}</span>}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer: Storage Quota + Sign Out + Push Status */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 border-t border-border pt-2">
|
||||
{quota && quota.total > 0 && (
|
||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||
)}
|
||||
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||
isSettingsActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("settings")}
|
||||
aria-current={isSettingsActive ? "page" : undefined}
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</Link>
|
||||
|
||||
<div className="w-8 border-t" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||
|
||||
{onShowShortcuts && (
|
||||
<button
|
||||
@@ -275,6 +413,10 @@ export function NavigationRail({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{quota && quota.total > 0 && (
|
||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||
)}
|
||||
|
||||
{isPushConnected != null && (
|
||||
<span
|
||||
className="relative group"
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, Plus, Pencil, Trash2, GripVertical, ExternalLink, PanelRight } from 'lucide-react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IconPicker } from './icon-picker';
|
||||
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
}
|
||||
|
||||
function SidebarAppForm({
|
||||
app,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
app?: SidebarApp;
|
||||
onSave: (data: SidebarAppFormData) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const isEditing = !!app;
|
||||
|
||||
const [formData, setFormData] = useState<SidebarAppFormData>({
|
||||
name: app?.name || '',
|
||||
url: app?.url || '',
|
||||
icon: app?.icon || 'Globe',
|
||||
openMode: app?.openMode || 'tab',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t('name_required');
|
||||
}
|
||||
if (!formData.url.trim()) {
|
||||
newErrors.url = t('url_required');
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(formData.url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
newErrors.url = t('url_invalid');
|
||||
}
|
||||
} catch {
|
||||
newErrors.url = t('url_invalid');
|
||||
}
|
||||
}
|
||||
if (!formData.icon) {
|
||||
newErrors.icon = t('icon_required');
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const SelectedIcon = formData.icon
|
||||
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="app-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="app-name"
|
||||
type="text"
|
||||
maxLength={50}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('name_placeholder')}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label htmlFor="app-url" className="block text-sm font-medium mb-1">
|
||||
{t('url_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="app-url"
|
||||
type="url"
|
||||
maxLength={2048}
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className={errors.url ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.url}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{t('open_mode_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: 'tab' })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
|
||||
formData.openMode === 'tab'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('open_new_tab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: 'inline' })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
|
||||
formData.openMode === 'inline'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<PanelRight className="w-4 h-4" />
|
||||
{t('open_inline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Picker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t('icon_label')} <span className="text-destructive">*</span>
|
||||
{SelectedIcon && (
|
||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<IconPicker
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||
/>
|
||||
{errors.icon && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.icon}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{isEditing ? t('update') : t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarAppsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const { sidebarApps, addSidebarApp, updateSidebarApp, removeSidebarApp } = useSettingsStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: () => {
|
||||
if (isCreating || editingId) {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setIsCreating(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
|
||||
updateSidebarApp(id, data);
|
||||
setEditingId(null);
|
||||
}, [updateSidebarApp]);
|
||||
|
||||
const handleDelete = useCallback(async (app: SidebarApp) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('delete_confirm_title'),
|
||||
message: t('delete_confirm', { name: app.name }),
|
||||
confirmText: t('delete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
removeSidebarApp(app.id);
|
||||
}, [removeSidebarApp, confirmDialog, t]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sidebar-apps-modal-title"
|
||||
className={cn(
|
||||
'bg-background border border-border rounded-lg shadow-xl',
|
||||
'w-full max-w-2xl max-h-[90vh] overflow-hidden',
|
||||
'animate-in zoom-in-95 duration-200'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 id="sidebar-apps-modal-title" className="text-lg font-semibold text-foreground">
|
||||
{t('modal_title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Create form */}
|
||||
{isCreating && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('add_new')}</h3>
|
||||
<SidebarAppForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button */}
|
||||
{!isCreating && !editingId && (
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('add_new')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Apps list */}
|
||||
<div className="space-y-3">
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
|
||||
if (editingId === app.id) {
|
||||
return (
|
||||
<div key={app.id} className="p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('edit_app')}</h3>
|
||||
<SidebarAppForm
|
||||
app={app}
|
||||
onSave={(data) => handleUpdate(app.id, data)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 p-3 border border-border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-md bg-muted">
|
||||
{AppIcon ? <AppIcon className="w-5 h-5 text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{app.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{app.url}</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded-full font-medium',
|
||||
app.openMode === 'inline'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
||||
)}>
|
||||
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(app.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(app)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{sidebarApps.length === 0 && !isCreating && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Plus className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('no_apps')}</p>
|
||||
<p className="text-xs mt-1">{t('no_apps_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/s
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
|
||||
interface SidebarProps {
|
||||
mailboxes: Mailbox[];
|
||||
@@ -361,6 +363,8 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity } = useAuthStore();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||
try {
|
||||
@@ -460,6 +464,17 @@ export function Sidebar({
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
return logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className={cn("object-contain flex-shrink-0", isCollapsed ? "w-6 h-6" : "w-6 h-6")}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Check, X, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, formatDateTime } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
const CALENDAR_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
@@ -164,6 +165,7 @@ export function CalendarManagementSettings() {
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
const tImport = useTranslations('calendar.import');
|
||||
const tSub = useTranslations('calendar.subscription');
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load calendars if not yet loaded
|
||||
@@ -563,7 +565,7 @@ export function CalendarManagementSettings() {
|
||||
</span>
|
||||
{sub.lastRefreshed && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { ArchiveMode } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
|
||||
|
||||
export function EmailSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const [isReorganizing, setIsReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
markAsReadDelay,
|
||||
@@ -19,6 +24,8 @@ export function EmailSettings() {
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
mailAttachmentAction,
|
||||
emailAlwaysLightMode,
|
||||
archiveMode,
|
||||
trustedSenders,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
@@ -31,6 +38,69 @@ export function EmailSettings() {
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { client } = useAuthStore.getState();
|
||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||
if (!client) return;
|
||||
|
||||
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
||||
if (!archiveMailbox) return;
|
||||
|
||||
setIsReorganizing(true);
|
||||
setReorganizeResult(null);
|
||||
|
||||
try {
|
||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||
|
||||
// Fetch all emails in the root archive mailbox
|
||||
const emails = await client.getEmailsInMailbox(archiveId);
|
||||
let movedCount = 0;
|
||||
|
||||
for (const email of emails) {
|
||||
const emailDate = new Date(email.receivedAt);
|
||||
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
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
yearMailbox = await client.createMailbox(year, archiveId);
|
||||
await fetchMailboxes(client);
|
||||
currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
}
|
||||
|
||||
if (archiveMode === 'year') {
|
||||
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
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
monthMailbox = await client.createMailbox(month, yearId);
|
||||
await fetchMailboxes(client);
|
||||
}
|
||||
await client.moveEmail(email.id, monthMailbox.id);
|
||||
movedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setReorganizeResult(t('archive_mode.reorganize_success', { count: movedCount }));
|
||||
} catch (error) {
|
||||
console.error('Failed to reorganize archive:', error);
|
||||
setReorganizeResult(t('archive_mode.reorganize_error'));
|
||||
} finally {
|
||||
setIsReorganizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Mark as Read */}
|
||||
@@ -67,6 +137,40 @@ export function EmailSettings() {
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Archive Mode */}
|
||||
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={archiveMode}
|
||||
onChange={(value) => updateSetting('archiveMode', value as ArchiveMode)}
|
||||
options={[
|
||||
{ value: 'single', label: t('archive_mode.single') },
|
||||
{ value: 'year', label: t('archive_mode.year') },
|
||||
{ value: 'month', label: t('archive_mode.month') },
|
||||
]}
|
||||
/>
|
||||
{archiveMode !== 'single' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleReorganizeArchive}
|
||||
disabled={isReorganizing}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
{isReorganizing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<FolderSync className="w-4 h-4" />
|
||||
)}
|
||||
<span>{t('archive_mode.reorganize')}</span>
|
||||
</button>
|
||||
{reorganizeResult && (
|
||||
<p className="text-xs text-muted-foreground">{reorganizeResult}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Permanently Delete Junk */}
|
||||
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
|
||||
<ToggleSwitch
|
||||
@@ -104,6 +208,14 @@ 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 */}
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Pencil, Trash2, ExternalLink, PanelRight, GripVertical } from "lucide-react";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
|
||||
import { IconPicker } from "@/components/layout/icon-picker";
|
||||
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: "tab" | "inline";
|
||||
}
|
||||
|
||||
function AppForm({
|
||||
app,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
app?: SidebarApp;
|
||||
onSave: (data: SidebarAppFormData) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations("sidebar_apps");
|
||||
const isEditing = !!app;
|
||||
|
||||
const [formData, setFormData] = useState<SidebarAppFormData>({
|
||||
name: app?.name || "",
|
||||
url: app?.url || "",
|
||||
icon: app?.icon || "Globe",
|
||||
openMode: app?.openMode || "tab",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t("name_required");
|
||||
}
|
||||
if (!formData.url.trim()) {
|
||||
newErrors.url = t("url_required");
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(formData.url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
newErrors.url = t("url_invalid");
|
||||
}
|
||||
} catch {
|
||||
newErrors.url = t("url_invalid");
|
||||
}
|
||||
}
|
||||
if (!formData.icon) {
|
||||
newErrors.icon = t("icon_required");
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const SelectedIcon = formData.icon
|
||||
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4 border border-border rounded-lg bg-secondary/30">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("name_label")}</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t("name_placeholder")}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-destructive mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("url_label")}</label>
|
||||
<Input
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.url && <p className="text-xs text-destructive mt-1">{errors.url}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1">{t("icon_label")}</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{SelectedIcon && (
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center">
|
||||
<SelectedIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{formData.icon}</span>
|
||||
</div>
|
||||
<IconPicker value={formData.icon} onChange={(icon) => setFormData({ ...formData, icon })} />
|
||||
{errors.icon && <p className="text-xs text-destructive mt-1">{errors.icon}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t("open_mode_label")}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: "tab" })}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-colors",
|
||||
formData.openMode === "tab"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("open_new_tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: "inline" })}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-colors",
|
||||
formData.openMode === "inline"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<PanelRight className="w-4 h-4" />
|
||||
{t("open_inline")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
{isEditing ? t("update") : t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAppsSettings() {
|
||||
const t = useTranslations("settings.sidebar_apps");
|
||||
const tApps = useTranslations("sidebar_apps");
|
||||
const { sidebarApps, keepAppsLoaded, addSidebarApp, updateSidebarApp, removeSidebarApp, updateSetting } = useSettingsStore();
|
||||
const [editingApp, setEditingApp] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const handleAdd = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setShowAddForm(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
|
||||
updateSidebarApp(id, data);
|
||||
setEditingApp(null);
|
||||
}, [updateSidebarApp]);
|
||||
|
||||
const handleDelete = useCallback(async (app: SidebarApp) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: tApps("delete_confirm_title"),
|
||||
message: tApps("delete_confirm", { name: app.name }),
|
||||
confirmText: tApps("delete"),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
removeSidebarApp(app.id);
|
||||
}, [confirmDialog, tApps, removeSidebarApp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<SettingItem label={t("keep_loaded")} description={t("keep_loaded_description")}>
|
||||
<ToggleSwitch
|
||||
checked={keepAppsLoaded}
|
||||
onChange={(v) => updateSetting("keepAppsLoaded", v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("manage_title")} description={t("manage_description")}>
|
||||
<div className="space-y-3">
|
||||
{sidebarApps.length === 0 && !showAddForm && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{tApps("no_apps_hint")}</p>
|
||||
)}
|
||||
|
||||
{sidebarApps.map((app) => {
|
||||
if (editingApp === app.id) {
|
||||
return (
|
||||
<AppForm
|
||||
key={app.id}
|
||||
app={app}
|
||||
onSave={(data) => handleUpdate(app.id, data)}
|
||||
onCancel={() => setEditingApp(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/50 flex-shrink-0" />
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
|
||||
{AppIcon ? <AppIcon className="w-4 h-4" /> : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{app.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{app.url}</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0",
|
||||
app.openMode === "inline"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{app.openMode === "inline" ? tApps("inline_badge") : tApps("tab_badge")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingApp(app.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{showAddForm && (
|
||||
<AppForm
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showAddForm && !editingApp && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{tApps("add_new")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeCertificateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
record: SmimeKeyRecord | SmimePublicCert | null;
|
||||
type: "private" | "public";
|
||||
}
|
||||
|
||||
export function SmimeCertificateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
record,
|
||||
type,
|
||||
}: SmimeCertificateModalProps) {
|
||||
const t = useTranslations("smime");
|
||||
const id = useId();
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
if (!isOpen || !record) return null;
|
||||
|
||||
const isExpired = new Date(record.notAfter) < new Date();
|
||||
const isNotYetValid = new Date(record.notBefore) > new Date();
|
||||
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: t("cert_subject"), value: record.subject ?? "" },
|
||||
{ label: t("cert_issuer"), value: record.issuer ?? "" },
|
||||
{ label: t("cert_email"), value: record.email },
|
||||
{
|
||||
label: t("cert_validity"),
|
||||
value: `${new Date(record.notBefore).toLocaleDateString()} — ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||
},
|
||||
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
||||
];
|
||||
|
||||
if ("serialNumber" in record) {
|
||||
rows.splice(2, 0, { label: t("cert_serial"), value: record.serialNumber });
|
||||
}
|
||||
|
||||
if ("algorithm" in record) {
|
||||
rows.push({ label: t("cert_algorithm"), value: record.algorithm });
|
||||
}
|
||||
|
||||
if ("capabilities" in record) {
|
||||
const caps: string[] = [];
|
||||
if (record.capabilities.canSign) caps.push(t("cap_sign"));
|
||||
if (record.capabilities.canEncrypt) caps.push(t("cap_encrypt"));
|
||||
rows.push({ label: t("cert_capabilities"), value: caps.join(", ") || t("cap_none") });
|
||||
}
|
||||
|
||||
if ("source" in record) {
|
||||
rows.push({ label: t("cert_source"), value: record.source });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
|
||||
{t("certificate_details")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{(isExpired || isNotYetValid) && (
|
||||
<div className="px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{isExpired ? t("cert_expired") : t("cert_not_yet_valid")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground mt-0.5 break-all font-mono">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 pb-6">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { KeyRound, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface SmimePassphraseDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (passphrase: string) => void | Promise<void>;
|
||||
title: string;
|
||||
description?: string;
|
||||
submitText?: string;
|
||||
error?: string | null;
|
||||
/** Show a second passphrase field for import/export confirmation. */
|
||||
showConfirm?: boolean;
|
||||
}
|
||||
|
||||
export function SmimePassphraseDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
description,
|
||||
submitText,
|
||||
error,
|
||||
showConfirm = false,
|
||||
}: SmimePassphraseDialogProps) {
|
||||
const t = useTranslations("smime");
|
||||
const id = useId();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const mismatch = showConfirm && passphrase !== confirm && confirm.length > 0;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!passphrase || (showConfirm && passphrase !== confirm)) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(passphrase);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassphrase("");
|
||||
setConfirm("");
|
||||
setShowPassword(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={description ? `${id}-desc` : undefined}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id={`${id}-title`}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
id={`${id}-desc`}
|
||||
className="text-sm text-muted-foreground mt-1"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t("passphrase_placeholder")}
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("hide_passphrase") : t("show_passphrase")}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("confirm_passphrase_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{mismatch && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("passphrase_mismatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-6 pb-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!passphrase || isSubmitting || (showConfirm && passphrase !== confirm)}
|
||||
>
|
||||
{isSubmitting ? t("processing") : (submitText ?? t("unlock"))}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
Lock,
|
||||
Unlock,
|
||||
Download,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "@/components/settings/settings-section";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
export function SmimeSettings() {
|
||||
const t = useTranslations("smime");
|
||||
const {
|
||||
keyRecords,
|
||||
publicCerts,
|
||||
identityKeyBindings,
|
||||
defaultSignIdentity,
|
||||
defaultEncrypt,
|
||||
rememberUnlockedKeys,
|
||||
autoImportSignerCerts,
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
importPKCS12,
|
||||
removeKeyRecord,
|
||||
removePublicCert,
|
||||
bindIdentityToKey,
|
||||
unlockKey,
|
||||
lockKey,
|
||||
setSignDefault,
|
||||
setEncryptDefault,
|
||||
setRememberUnlockedKeys,
|
||||
setAutoImportSignerCerts,
|
||||
isKeyUnlocked,
|
||||
setError,
|
||||
} = useSmimeStore();
|
||||
|
||||
const { identities } = useIdentityStore();
|
||||
|
||||
// Local UI state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
|
||||
const [unlockTargetId, setUnlockTargetId] = useState<string | null>(null);
|
||||
const [certModalRecord, setCertModalRecord] = useState<SmimeKeyRecord | SmimePublicCert | null>(null);
|
||||
const [certModalType, setCertModalType] = useState<"private" | "public">("private");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [unlockError, setUnlockError] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<ArrayBuffer | null>(null);
|
||||
const [pendingP12Pass, setPendingP12Pass] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pubCertInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// State for the two-step PKCS#12 flow
|
||||
const [importStep, setImportStep] = useState<"p12" | "storage">("p12");
|
||||
|
||||
// Export flow state
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [exportTargetRecord, setExportTargetRecord] = useState<SmimeKeyRecord | null>(null);
|
||||
const [exportStep, setExportStep] = useState<"storage" | "export">("storage");
|
||||
const [exportStoragePass, setExportStoragePass] = useState("");
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setPendingFile(reader.result as ArrayBuffer);
|
||||
setImportStep("p12");
|
||||
setImportError(null);
|
||||
setImportDialogOpen(true);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
// Reset so same file can be re-selected
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleImportSubmit = async (passphrase: string) => {
|
||||
if (importStep === "p12") {
|
||||
setPendingP12Pass(passphrase);
|
||||
setImportStep("storage");
|
||||
setImportError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storage passphrase step
|
||||
if (!pendingFile) return;
|
||||
try {
|
||||
await importPKCS12(pendingFile, pendingP12Pass, passphrase);
|
||||
setImportDialogOpen(false);
|
||||
setPendingFile(null);
|
||||
setPendingP12Pass("");
|
||||
setImportError(null);
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : "Import failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Public cert import ─────────────────────────────────────────
|
||||
|
||||
const handlePublicCertFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const store = useSmimeStore.getState();
|
||||
await store.importPublicCert(reader.result as ArrayBuffer, "manual");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to import certificate");
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// ── Unlock ─────────────────────────────────────────────────────
|
||||
|
||||
const handleUnlockRequest = (id: string) => {
|
||||
setUnlockTargetId(id);
|
||||
setUnlockError(null);
|
||||
setUnlockDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleUnlockSubmit = async (passphrase: string) => {
|
||||
if (!unlockTargetId) return;
|
||||
try {
|
||||
await unlockKey(unlockTargetId, passphrase);
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockTargetId(null);
|
||||
setUnlockError(null);
|
||||
} catch (err) {
|
||||
setUnlockError(err instanceof Error ? err.message : "Unlock failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Export flow ────────────────────────────────────────────────
|
||||
|
||||
const handleExportRequest = (record: SmimeKeyRecord) => {
|
||||
setExportTargetRecord(record);
|
||||
setExportStep("storage");
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
setExportDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExportSubmit = async (passphrase: string) => {
|
||||
if (!exportTargetRecord) return;
|
||||
|
||||
if (exportStep === "storage") {
|
||||
// Verify storage passphrase by attempting to decrypt
|
||||
try {
|
||||
const { decryptPrivateKeyBytes } = await import("@/lib/smime/pkcs12-import");
|
||||
await decryptPrivateKeyBytes(exportTargetRecord, passphrase);
|
||||
setExportStoragePass(passphrase);
|
||||
setExportStep("export");
|
||||
setExportError(null);
|
||||
} catch {
|
||||
setExportError(t("incorrect_passphrase"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Export passphrase step
|
||||
try {
|
||||
const p12Bytes = await exportPkcs12(exportTargetRecord, exportStoragePass, passphrase);
|
||||
const filename = `${exportTargetRecord.email.replace(/[^a-zA-Z0-9.-]/g, '_')}.p12`;
|
||||
downloadPkcs12(p12Bytes, filename);
|
||||
setExportDialogOpen(false);
|
||||
setExportTargetRecord(null);
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : "Export failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const isExpired = (dateStr: string) => new Date(dateStr) < new Date();
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getBoundIdentityNames = (keyId: string): string[] => {
|
||||
return Object.entries(identityKeyBindings)
|
||||
.filter(([, kId]) => kId === keyId)
|
||||
.map(([identityId]) => {
|
||||
const identity = identities.find((i) => i.id === identityId);
|
||||
return identity?.email ?? identityId;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<div className="px-4 py-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Your Certificates ──────────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("your_certificates")}
|
||||
description={t("your_certificates_desc")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{keyRecords.map((record) => {
|
||||
const expired = isExpired(record.notAfter);
|
||||
const unlocked = isKeyUnlocked(record.id);
|
||||
const boundIdentities = getBoundIdentityNames(record.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${expired ? "bg-destructive/10" : "bg-primary/10"}`}>
|
||||
{expired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{record.email || record.subject}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{record.issuer} · {t("expires")} {formatDate(record.notAfter)}
|
||||
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
|
||||
</p>
|
||||
{boundIdentities.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("bound_to")}: {boundIdentities.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{unlocked ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => lockKey(record.id)}
|
||||
title={t("lock")}
|
||||
>
|
||||
<Unlock className="w-4 h-4 text-green-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleUnlockRequest(record.id)}
|
||||
title={t("unlock")}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCertModalRecord(record);
|
||||
setCertModalType("private");
|
||||
}}
|
||||
title={t("details")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleExportRequest(record)}
|
||||
title={t("export")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeKeyRecord(record.id)}
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{keyRecords.length === 0 && !isLoading && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("no_certificates")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".p12,.pfx"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("import_pkcs12")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Recipient Certificates ─────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("recipient_certificates")}
|
||||
description={t("recipient_certificates_desc")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{publicCerts.map((cert) => {
|
||||
const expired = isExpired(cert.notAfter);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cert.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{cert.email || cert.subject}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cert.issuer} · {cert.source}
|
||||
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCertModalRecord(cert);
|
||||
setCertModalType("public");
|
||||
}}
|
||||
title={t("details")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePublicCert(cert.id)}
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{publicCerts.length === 0 && !isLoading && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("no_recipient_certs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={pubCertInputRef}
|
||||
type="file"
|
||||
accept=".pem,.cer,.crt,.der"
|
||||
className="hidden"
|
||||
onChange={handlePublicCertFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => pubCertInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("import_public_cert")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Identity Bindings ──────────────────────────────────── */}
|
||||
{identities.length > 0 && keyRecords.length > 0 && (
|
||||
<SettingsSection
|
||||
title={t("identity_bindings")}
|
||||
description={t("identity_bindings_desc")}
|
||||
>
|
||||
{identities.map((identity) => {
|
||||
const boundKeyId = identityKeyBindings[identity.id];
|
||||
return (
|
||||
<SettingItem key={identity.id} label={identity.email}>
|
||||
<select
|
||||
value={boundKeyId ?? ""}
|
||||
onChange={(e) =>
|
||||
bindIdentityToKey(identity.id, e.target.value || null)
|
||||
}
|
||||
className="text-sm bg-background border border-border rounded-md px-2 py-1"
|
||||
>
|
||||
<option value="">{t("no_key_bound")}</option>
|
||||
{keyRecords.map((kr) => (
|
||||
<option key={kr.id} value={kr.id}>
|
||||
{kr.email} ({kr.algorithm})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* ── Defaults ───────────────────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("defaults_title")}
|
||||
description={t("defaults_desc")}
|
||||
>
|
||||
<SettingItem
|
||||
label={t("encrypt_by_default")}
|
||||
description={t("encrypt_by_default_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={defaultEncrypt}
|
||||
onChange={setEncryptDefault}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("remember_unlocked")}
|
||||
description={t("remember_unlocked_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={rememberUnlockedKeys}
|
||||
onChange={setRememberUnlockedKeys}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("auto_import_signer_certs")}
|
||||
description={t("auto_import_signer_certs_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={autoImportSignerCerts}
|
||||
onChange={setAutoImportSignerCerts}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{identities.map((identity) => {
|
||||
const bound = identityKeyBindings[identity.id];
|
||||
if (!bound) return null;
|
||||
return (
|
||||
<SettingItem
|
||||
key={identity.id}
|
||||
label={`${t("sign_default_for")} ${identity.email}`}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={defaultSignIdentity[identity.id] ?? false}
|
||||
onChange={(v) => setSignDefault(identity.id, v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Dialogs ────────────────────────────────────────────── */}
|
||||
<SmimePassphraseDialog
|
||||
isOpen={importDialogOpen}
|
||||
onClose={() => {
|
||||
setImportDialogOpen(false);
|
||||
setPendingFile(null);
|
||||
setPendingP12Pass("");
|
||||
setImportError(null);
|
||||
setImportStep("p12");
|
||||
}}
|
||||
onSubmit={handleImportSubmit}
|
||||
title={importStep === "p12" ? t("enter_p12_passphrase") : t("enter_storage_passphrase")}
|
||||
description={importStep === "p12" ? t("p12_passphrase_desc") : t("storage_passphrase_desc")}
|
||||
submitText={importStep === "p12" ? t("next") : t("import")}
|
||||
error={importError}
|
||||
showConfirm={importStep === "storage"}
|
||||
/>
|
||||
|
||||
<SmimePassphraseDialog
|
||||
isOpen={unlockDialogOpen}
|
||||
onClose={() => {
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockTargetId(null);
|
||||
setUnlockError(null);
|
||||
}}
|
||||
onSubmit={handleUnlockSubmit}
|
||||
title={t("unlock_key")}
|
||||
description={t("unlock_key_desc")}
|
||||
error={unlockError}
|
||||
/>
|
||||
|
||||
<SmimeCertificateModal
|
||||
isOpen={!!certModalRecord}
|
||||
onClose={() => setCertModalRecord(null)}
|
||||
record={certModalRecord}
|
||||
type={certModalType}
|
||||
/>
|
||||
|
||||
<SmimePassphraseDialog
|
||||
isOpen={exportDialogOpen}
|
||||
onClose={() => {
|
||||
setExportDialogOpen(false);
|
||||
setExportTargetRecord(null);
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
setExportStep("storage");
|
||||
}}
|
||||
onSubmit={handleExportSubmit}
|
||||
title={exportStep === "storage" ? t("enter_storage_passphrase") : t("enter_export_passphrase")}
|
||||
description={exportStep === "storage" ? t("export_storage_desc") : t("export_passphrase_desc")}
|
||||
submitText={exportStep === "storage" ? t("next") : t("export")}
|
||||
error={exportError}
|
||||
showConfirm={exportStep === "export"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ services:
|
||||
image: ghcr.io/bulwarkmail/webmail:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0 # Use "::" for IPv6
|
||||
- PORT=3000
|
||||
env_file:
|
||||
- .env.local
|
||||
healthcheck:
|
||||
|
||||
@@ -13,6 +13,9 @@ interface ConfigData {
|
||||
settingsSyncEnabled: boolean;
|
||||
stalwartFeaturesEnabled: boolean;
|
||||
devMode: boolean;
|
||||
faviconUrl: string;
|
||||
appLogoLightUrl: string;
|
||||
appLogoDarkUrl: string;
|
||||
loginLogoLightUrl: string;
|
||||
loginLogoDarkUrl: string;
|
||||
loginCompanyName: string;
|
||||
@@ -79,6 +82,9 @@ export function useConfig(): AppConfig {
|
||||
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
|
||||
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
|
||||
devMode: configCache?.devMode || false,
|
||||
faviconUrl: configCache?.faviconUrl || '/branding/Bulwark_Favicon.svg',
|
||||
appLogoLightUrl: configCache?.appLogoLightUrl || '',
|
||||
appLogoDarkUrl: configCache?.appLogoDarkUrl || '',
|
||||
loginLogoLightUrl: configCache?.loginLogoLightUrl || '/branding/Bulwark_Logo_Color.svg',
|
||||
loginLogoDarkUrl: configCache?.loginLogoDarkUrl || '/branding/Bulwark_Logo_White.svg',
|
||||
loginCompanyName: configCache?.loginCompanyName || '',
|
||||
@@ -103,6 +109,9 @@ export function useConfig(): AppConfig {
|
||||
settingsSyncEnabled: configCache.settingsSyncEnabled,
|
||||
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
|
||||
devMode: configCache.devMode,
|
||||
faviconUrl: configCache.faviconUrl,
|
||||
appLogoLightUrl: configCache.appLogoLightUrl,
|
||||
appLogoDarkUrl: configCache.appLogoDarkUrl,
|
||||
loginLogoLightUrl: configCache.loginLogoLightUrl,
|
||||
loginLogoDarkUrl: configCache.loginLogoDarkUrl,
|
||||
loginCompanyName: configCache.loginCompanyName,
|
||||
@@ -128,6 +137,9 @@ export function useConfig(): AppConfig {
|
||||
settingsSyncEnabled: data.settingsSyncEnabled,
|
||||
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
|
||||
devMode: data.devMode,
|
||||
faviconUrl: data.faviconUrl,
|
||||
appLogoLightUrl: data.appLogoLightUrl,
|
||||
appLogoDarkUrl: data.appLogoDarkUrl,
|
||||
loginLogoLightUrl: data.loginLogoLightUrl,
|
||||
loginLogoDarkUrl: data.loginLogoDarkUrl,
|
||||
loginCompanyName: data.loginCompanyName,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
export interface InlineAppState {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function useSidebarApps() {
|
||||
const [showAppsModal, setShowAppsModal] = useState(false);
|
||||
const [inlineApp, setInlineApp] = useState<InlineAppState | null>(null);
|
||||
const [loadedApps, setLoadedApps] = useState<InlineAppState[]>([]);
|
||||
const keepAppsLoaded = useSettingsStore((s) => s.keepAppsLoaded);
|
||||
|
||||
const handleManageApps = useCallback(() => {
|
||||
setShowAppsModal(true);
|
||||
}, []);
|
||||
|
||||
const handleInlineApp = useCallback((appId: string, url: string, name: string) => {
|
||||
const app = { id: appId, url, name };
|
||||
setInlineApp(app);
|
||||
setLoadedApps((prev) => {
|
||||
if (prev.some((a) => a.id === appId)) return prev;
|
||||
return [...prev, app];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeInlineApp = useCallback(() => {
|
||||
if (!keepAppsLoaded) {
|
||||
setLoadedApps((prev) => prev.filter((a) => a.id !== inlineApp?.id));
|
||||
}
|
||||
setInlineApp(null);
|
||||
}, [keepAppsLoaded, inlineApp]);
|
||||
|
||||
const closeAppsModal = useCallback(() => {
|
||||
setShowAppsModal(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showAppsModal,
|
||||
inlineApp,
|
||||
loadedApps: keepAppsLoaded ? loadedApps : (inlineApp ? [inlineApp] : []),
|
||||
handleManageApps,
|
||||
handleInlineApp,
|
||||
closeInlineApp,
|
||||
closeAppsModal,
|
||||
};
|
||||
}
|
||||
@@ -335,10 +335,7 @@ describe('formatEventSummary', () => {
|
||||
duration: 'PT1H30M',
|
||||
});
|
||||
expect(summary.start).toBe('2026-02-17T10:00:00');
|
||||
expect(summary.end).toBeTruthy();
|
||||
const endDate = new Date(summary.end!);
|
||||
expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1);
|
||||
expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30);
|
||||
expect(summary.end).toBe('2026-02-17T11:30:00');
|
||||
});
|
||||
|
||||
it('uses utcStart and utcEnd when available', () => {
|
||||
@@ -349,6 +346,38 @@ describe('formatEventSummary', () => {
|
||||
});
|
||||
expect(summary.start).toBe('2026-02-17T15:00:00Z');
|
||||
expect(summary.end).toBe('2026-02-17T16:00:00Z');
|
||||
expect(summary.isAllDay).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers local start over utcStart for all-day events', () => {
|
||||
const summary = formatEventSummary({
|
||||
utcStart: '2026-03-16T00:00:00Z',
|
||||
utcEnd: '2026-03-17T00:00:00Z',
|
||||
start: '2026-03-16T00:00:00',
|
||||
showWithoutTime: true,
|
||||
});
|
||||
expect(summary.start).toBe('2026-03-16T00:00:00');
|
||||
expect(summary.end).toBe('2026-03-17T00:00:00Z');
|
||||
expect(summary.isAllDay).toBe(true);
|
||||
});
|
||||
|
||||
it('computes all-day end in local format when utcEnd is missing', () => {
|
||||
const summary = formatEventSummary({
|
||||
start: '2026-03-16T00:00:00',
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
});
|
||||
expect(summary.start).toBe('2026-03-16T00:00:00');
|
||||
expect(summary.end).toBe('2026-03-17T00:00:00');
|
||||
expect(summary.isAllDay).toBe(true);
|
||||
});
|
||||
|
||||
it('returns local format end for timed events with local start', () => {
|
||||
const summary = formatEventSummary({
|
||||
start: '2026-02-17T10:00:00',
|
||||
duration: 'PT1H30M',
|
||||
});
|
||||
expect(summary.end).toBe('2026-02-17T11:30:00');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ describe('config API route', () => {
|
||||
delete process.env.SETTINGS_SYNC_ENABLED;
|
||||
delete process.env.STALWART_FEATURES;
|
||||
delete process.env.DEV_MOCK_JMAP;
|
||||
delete process.env.FAVICON_URL;
|
||||
delete process.env.APP_LOGO_LIGHT_URL;
|
||||
delete process.env.APP_LOGO_DARK_URL;
|
||||
delete process.env.LOGIN_COMPANY_NAME;
|
||||
delete process.env.LOGIN_IMPRINT_URL;
|
||||
delete process.env.LOGIN_PRIVACY_POLICY_URL;
|
||||
@@ -60,6 +63,9 @@ describe('config API route', () => {
|
||||
expect(config.loginImprintUrl).toBe('');
|
||||
expect(config.loginPrivacyPolicyUrl).toBe('');
|
||||
expect(config.loginWebsiteUrl).toBe('');
|
||||
expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg');
|
||||
expect(config.appLogoLightUrl).toBe('');
|
||||
expect(config.appLogoDarkUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should use runtime env vars over defaults', async () => {
|
||||
@@ -142,4 +148,16 @@ describe('config API route', () => {
|
||||
|
||||
expect(config.stalwartFeaturesEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return custom favicon and app logo URLs', async () => {
|
||||
process.env.FAVICON_URL = '/branding/custom-favicon.svg';
|
||||
process.env.APP_LOGO_LIGHT_URL = '/branding/my-logo.svg';
|
||||
process.env.APP_LOGO_DARK_URL = '/branding/my-logo-white.svg';
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
expect(config.faviconUrl).toBe('/branding/custom-favicon.svg');
|
||||
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
|
||||
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,22 @@ function extractUsedKeys(filePath: string): string[] {
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
function collectUsedKeysByFile(files: string[]): Map<string, string[]> {
|
||||
const keyToFiles = new Map<string, string[]>();
|
||||
|
||||
for (const filePath of files) {
|
||||
for (const key of extractUsedKeys(filePath)) {
|
||||
const existing = keyToFiles.get(key) ?? [];
|
||||
if (!existing.includes(filePath)) {
|
||||
existing.push(filePath);
|
||||
keyToFiles.set(key, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keyToFiles;
|
||||
}
|
||||
|
||||
const locales = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((entry) => fs.statSync(path.join(localesDir, entry)).isDirectory());
|
||||
@@ -120,19 +136,22 @@ describe('translations completeness', () => {
|
||||
describe('translations used in source code exist in en locale', () => {
|
||||
const srcDirs = ['components', 'app', 'hooks', 'lib', 'stores', 'contexts'].map((d) => path.join(rootDir, d));
|
||||
const allFiles = srcDirs.flatMap((d) => getSourceFiles(d));
|
||||
const usedKeys = new Set<string>();
|
||||
for (const f of allFiles) {
|
||||
for (const k of extractUsedKeys(f)) {
|
||||
usedKeys.add(k);
|
||||
}
|
||||
}
|
||||
const usedKeysByFile = collectUsedKeysByFile(allFiles);
|
||||
const usedKeys = [...usedKeysByFile.keys()].sort();
|
||||
|
||||
it('all translation keys referenced in source should exist in en locale', () => {
|
||||
const missing = [...usedKeys].sort().filter((key) => resolveKey(referenceData, key) === undefined);
|
||||
const missing = usedKeys.filter((key) => resolveKey(referenceData, key) === undefined);
|
||||
const details = missing.map((key) => {
|
||||
const relativeFiles = (usedKeysByFile.get(key) ?? [])
|
||||
.map((filePath) => path.relative(rootDir, filePath))
|
||||
.sort();
|
||||
|
||||
return `${key}\n used in:\n - ${relativeFiles.join('\n - ')}`;
|
||||
});
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`${missing.length} translation key(s) used in source code but missing from en locale:\n${missing.join('\n')}`,
|
||||
`${missing.length} translation key(s) used in source code but missing from en locale:\n${details.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -449,6 +449,7 @@ export interface EventSummary {
|
||||
title: string;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
isAllDay: boolean;
|
||||
location: string | null;
|
||||
organizer: string | null;
|
||||
organizerEmail: string | null;
|
||||
@@ -495,6 +496,8 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
if (!organizer) organizer = organizerEmail;
|
||||
}
|
||||
|
||||
const isAllDay = event.showWithoutTime ?? false;
|
||||
|
||||
let end: string | null = null;
|
||||
if (event.utcEnd) {
|
||||
end = event.utcEnd;
|
||||
@@ -502,10 +505,17 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
end = addDurationToDate(event.start, event.duration, event.timeZone);
|
||||
}
|
||||
|
||||
// For all-day events, prefer the local start (no timezone) to avoid
|
||||
// UTC conversion shifting the displayed date in non-UTC timezones.
|
||||
const start = isAllDay
|
||||
? (event.start || null)
|
||||
: (event.utcStart || event.start || null);
|
||||
|
||||
return {
|
||||
title: event.title || '',
|
||||
start: event.utcStart || event.start || null,
|
||||
start,
|
||||
end,
|
||||
isAllDay,
|
||||
location,
|
||||
organizer,
|
||||
organizerEmail,
|
||||
@@ -530,6 +540,19 @@ function addDurationToDate(start: string, duration: string, _timeZone?: string |
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
date.setSeconds(date.getSeconds() + seconds);
|
||||
|
||||
// If the input is a local datetime (no UTC 'Z' suffix), return a local
|
||||
// format string so that all-day date arithmetic isn't shifted by the
|
||||
// browser's UTC offset (toISOString converts to UTC).
|
||||
if (!start.endsWith('Z') && !start.includes('+')) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const h = String(date.getHours()).padStart(2, '0');
|
||||
const min = String(date.getMinutes()).padStart(2, '0');
|
||||
const s = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}T${h}:${min}:${s}`;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
|
||||
+154
-10
@@ -556,6 +556,22 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
||||
const allEmails: Email[] = [];
|
||||
let position = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { emails, hasMore } = await this.getEmails(mailboxId, undefined, batchSize, position);
|
||||
allEmails.push(...emails);
|
||||
if (!hasMore || emails.length === 0) break;
|
||||
position += emails.length;
|
||||
}
|
||||
|
||||
return allEmails;
|
||||
}
|
||||
|
||||
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||
if (tagIds.length === 0) return {};
|
||||
try {
|
||||
@@ -616,7 +632,7 @@ export class JMAPClient {
|
||||
"receivedAt", "sentAt", "from", "to", "cc", "bcc", "replyTo",
|
||||
"subject", "preview", "textBody", "htmlBody", "bodyValues",
|
||||
"hasAttachment", "attachments", "messageId", "inReplyTo",
|
||||
"references", "headers",
|
||||
"references", "headers", "bodyStructure", "blobId",
|
||||
],
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
@@ -1490,6 +1506,7 @@ export class JMAPClient {
|
||||
dtStart?: string;
|
||||
dtEnd?: string;
|
||||
timeZone?: string;
|
||||
isAllDay?: boolean;
|
||||
sequence?: number;
|
||||
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||
identityId?: string;
|
||||
@@ -1542,20 +1559,30 @@ export class JMAPClient {
|
||||
`DTSTAMP:${now}`,
|
||||
];
|
||||
if (opts.dtStart) {
|
||||
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
|
||||
// If TZID is included, it's a parameter on the property
|
||||
if (formatted.startsWith('TZID=')) {
|
||||
lines.push(`DTSTART;${formatted}`);
|
||||
if (opts.isAllDay) {
|
||||
// RFC 5545 §3.3.4: all-day events use VALUE=DATE (date-only, no time)
|
||||
const dateOnly = opts.dtStart.replace(/[-]/g, '').substring(0, 8);
|
||||
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||||
} else {
|
||||
lines.push(`DTSTART:${formatted}`);
|
||||
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
|
||||
if (formatted.startsWith('TZID=')) {
|
||||
lines.push(`DTSTART;${formatted}`);
|
||||
} else {
|
||||
lines.push(`DTSTART:${formatted}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.dtEnd) {
|
||||
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
|
||||
if (formatted.startsWith('TZID=')) {
|
||||
lines.push(`DTEND;${formatted}`);
|
||||
if (opts.isAllDay) {
|
||||
const dateOnly = opts.dtEnd.replace(/[-]/g, '').substring(0, 8);
|
||||
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
||||
} else {
|
||||
lines.push(`DTEND:${formatted}`);
|
||||
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
|
||||
if (formatted.startsWith('TZID=')) {
|
||||
lines.push(`DTEND;${formatted}`);
|
||||
} else {
|
||||
lines.push(`DTEND:${formatted}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.summary) {
|
||||
@@ -2927,4 +2954,121 @@ export class JMAPClient {
|
||||
setLastStates(states: AccountStates): void {
|
||||
this.lastStates = { ...states };
|
||||
}
|
||||
|
||||
// ── S/MIME raw-email helpers ─────────────────────────────────────
|
||||
|
||||
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await this.authenticatedFetch(url, {});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Import a raw MIME message blob into the account. */
|
||||
async importRawEmail(
|
||||
blob: Blob,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
): Promise<string> {
|
||||
// First upload the blob
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
|
||||
// Then import via Email/import
|
||||
const response = await this.request([
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
emails: {
|
||||
'smime-import': {
|
||||
blobId,
|
||||
mailboxIds,
|
||||
keywords: keywords ?? { '$seen': true },
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
const importResult = response.methodResponses?.[0]?.[1];
|
||||
if (importResult?.notCreated?.['smime-import']) {
|
||||
const err = importResult.notCreated['smime-import'];
|
||||
throw new Error(err.description || err.type || 'Failed to import email');
|
||||
}
|
||||
|
||||
const emailId = importResult?.created?.['smime-import']?.id;
|
||||
if (!emailId) {
|
||||
throw new Error('Email import succeeded but no ID returned');
|
||||
}
|
||||
return emailId;
|
||||
}
|
||||
|
||||
/** Submit an already-imported email for delivery. */
|
||||
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.accountId,
|
||||
create: { 'smime-submit': { emailId, identityId } },
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notCreated?.['smime-submit']) {
|
||||
const err = result.notCreated['smime-submit'];
|
||||
throw new Error(err.description || err.type || 'Failed to submit email');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a raw S/MIME message, move it to the Sent mailbox, and submit it.
|
||||
* Encapsulates the full import → update → submit flow.
|
||||
*/
|
||||
async sendRawEmail(
|
||||
blob: Blob,
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
draftMailboxId?: string,
|
||||
): Promise<void> {
|
||||
// Upload the raw message
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
|
||||
// Import into Sent, mark as seen, and submit — all in one request
|
||||
const methodCalls: [string, Record<string, unknown>, string][] = [
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
emails: {
|
||||
'raw-import': {
|
||||
blobId,
|
||||
mailboxIds: { [sentMailboxId]: true },
|
||||
keywords: { '$seen': true },
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
'raw-submit': {
|
||||
emailId: '#raw-import',
|
||||
identityId,
|
||||
},
|
||||
},
|
||||
}, '1'],
|
||||
];
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
// Check for errors
|
||||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||||
if (methodName.endsWith('/error')) {
|
||||
throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`);
|
||||
}
|
||||
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
|
||||
if (r.notCreated) {
|
||||
const firstErr = Object.values(r.notCreated)[0];
|
||||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export interface Email {
|
||||
verdict: string;
|
||||
explanation: string;
|
||||
};
|
||||
// S/MIME support
|
||||
blobId?: string;
|
||||
bodyStructure?: EmailBodyPart;
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import {
|
||||
pemToDer,
|
||||
derToPem,
|
||||
isPem,
|
||||
parseCertificateDer,
|
||||
parseCertificatePemOrDer,
|
||||
computeFingerprint,
|
||||
classifyCapabilities,
|
||||
extractCertificateInfo,
|
||||
} from '../certificate-utils';
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
|
||||
// Generate a self-signed test certificate using Web Crypto + pkijs
|
||||
let testCertDer: ArrayBuffer;
|
||||
let testCert: pkijs.Certificate;
|
||||
let testKeyPair: globalThis.CryptoKeyPair;
|
||||
|
||||
beforeAll(async () => {
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
|
||||
// Generate RSA key pair
|
||||
testKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// Build a minimal self-signed X.509 certificate
|
||||
testCert = new pkijs.Certificate();
|
||||
testCert.version = 2; // v3
|
||||
testCert.serialNumber = new asn1js.Integer({ value: 1 });
|
||||
|
||||
testCert.issuer.typesAndValues.push(new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3', // CN
|
||||
value: new asn1js.Utf8String({ value: 'Test CA' }),
|
||||
}));
|
||||
|
||||
testCert.subject.typesAndValues.push(new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3', // CN
|
||||
value: new asn1js.Utf8String({ value: 'Test User' }),
|
||||
}));
|
||||
|
||||
testCert.subject.typesAndValues.push(new pkijs.AttributeTypeAndValue({
|
||||
type: '1.2.840.113549.1.9.1', // emailAddress
|
||||
value: new asn1js.IA5String({ value: 'test@example.com' }),
|
||||
}));
|
||||
|
||||
testCert.notBefore.value = new Date('2024-01-01T00:00:00Z');
|
||||
testCert.notAfter.value = new Date('2030-12-31T23:59:59Z');
|
||||
|
||||
await testCert.subjectPublicKeyInfo.importKey(testKeyPair.publicKey, cryptoEngine);
|
||||
|
||||
// Add KeyUsage extension: digitalSignature + keyEncipherment
|
||||
const bitArray = new ArrayBuffer(1);
|
||||
const bitView = new Uint8Array(bitArray);
|
||||
bitView[0] = 0b10100000; // digitalSignature (bit 0) + keyEncipherment (bit 2)
|
||||
|
||||
testCert.extensions = [
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.15', // keyUsage
|
||||
critical: true,
|
||||
extnValue: new asn1js.OctetString({
|
||||
valueHex: new Uint8Array(new asn1js.BitString({
|
||||
valueHex: bitArray,
|
||||
unusedBits: 3,
|
||||
}).toBER(false)),
|
||||
}).toBER(false) as ArrayBuffer,
|
||||
parsedValue: {
|
||||
digitalSignature: true,
|
||||
contentCommitment: false,
|
||||
keyEncipherment: true,
|
||||
dataEncipherment: false,
|
||||
keyAgreement: false,
|
||||
keyCertSign: false,
|
||||
cRLSign: false,
|
||||
encipherOnly: false,
|
||||
decipherOnly: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await testCert.sign(testKeyPair.privateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
// toBER may return a non-standard ArrayBuffer in jsdom; normalize it
|
||||
const rawDer = testCert.toSchema(true).toBER(false);
|
||||
testCertDer = new Uint8Array(rawDer).buffer;
|
||||
});
|
||||
|
||||
describe('certificate-utils', () => {
|
||||
describe('pemToDer / derToPem roundtrip', () => {
|
||||
it('converts PEM to DER and back', () => {
|
||||
const pem = derToPem(testCertDer, 'CERTIFICATE');
|
||||
expect(pem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(pem).toContain('-----END CERTIFICATE-----');
|
||||
|
||||
const der2 = pemToDer(pem);
|
||||
expect(new Uint8Array(der2)).toEqual(new Uint8Array(testCertDer));
|
||||
});
|
||||
|
||||
it('derToPem wraps lines at 64 chars', () => {
|
||||
const pem = derToPem(testCertDer, 'CERTIFICATE');
|
||||
const lines = pem.split('\n');
|
||||
// All content lines (not headers) should be <= 64 chars
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('-----')) {
|
||||
expect(line.length).toBeLessThanOrEqual(64);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPem', () => {
|
||||
it('returns true for certificate PEM', () => {
|
||||
expect(isPem('-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for PKCS12 PEM', () => {
|
||||
expect(isPem('-----BEGIN PKCS12-----\ndata\n-----END PKCS12-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for private key PEM', () => {
|
||||
expect(isPem('-----BEGIN PRIVATE KEY-----\ndata\n-----END PRIVATE KEY-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for encrypted private key PEM', () => {
|
||||
expect(isPem('-----BEGIN ENCRYPTED PRIVATE KEY-----\ndata\n-----END ENCRYPTED PRIVATE KEY-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-PEM data', () => {
|
||||
expect(isPem('hello world')).toBe(false);
|
||||
expect(isPem('')).toBe(false);
|
||||
expect(isPem('MIIB...')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCertificateDer', () => {
|
||||
it('parses a valid DER certificate', () => {
|
||||
const cert = parseCertificateDer(testCertDer);
|
||||
expect(cert).toBeInstanceOf(pkijs.Certificate);
|
||||
});
|
||||
|
||||
it('throws on invalid DER data', () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
expect(() => parseCertificateDer(garbage)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCertificatePemOrDer', () => {
|
||||
it('parses DER ArrayBuffer', () => {
|
||||
const cert = parseCertificatePemOrDer(testCertDer);
|
||||
expect(cert).toBeInstanceOf(pkijs.Certificate);
|
||||
});
|
||||
|
||||
it('parses PEM string', () => {
|
||||
const pem = derToPem(testCertDer, 'CERTIFICATE');
|
||||
const cert = parseCertificatePemOrDer(pem);
|
||||
expect(cert).toBeInstanceOf(pkijs.Certificate);
|
||||
});
|
||||
|
||||
it('throws on non-PEM string', () => {
|
||||
expect(() => parseCertificatePemOrDer('not a pem')).toThrow('String input is not PEM-encoded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeFingerprint', () => {
|
||||
it('returns hex fingerprint with colons', async () => {
|
||||
const fp = await computeFingerprint(testCertDer);
|
||||
expect(fp).toMatch(/^[0-9a-f]{2}(:[0-9a-f]{2}){31}$/);
|
||||
});
|
||||
|
||||
it('is deterministic', async () => {
|
||||
const fp1 = await computeFingerprint(testCertDer);
|
||||
const fp2 = await computeFingerprint(testCertDer);
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyCapabilities', () => {
|
||||
it('detects sign + encrypt from KeyUsage', () => {
|
||||
const caps = classifyCapabilities(testCert);
|
||||
expect(caps.canSign).toBe(true);
|
||||
expect(caps.canEncrypt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCertificateInfo', () => {
|
||||
it('extracts full certificate metadata', async () => {
|
||||
const info = await extractCertificateInfo(testCert, testCertDer);
|
||||
|
||||
expect(info.subject).toContain('CN=Test User');
|
||||
expect(info.issuer).toContain('CN=Test CA');
|
||||
expect(info.notBefore).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(info.notAfter).toBe('2030-12-31T23:59:59.000Z');
|
||||
expect(info.fingerprint).toMatch(/^[0-9a-f]{2}(:[0-9a-f]{2}){31}$/);
|
||||
expect(info.algorithm).toMatch(/^RSA/);
|
||||
expect(info.emailAddresses).toContain('test@example.com');
|
||||
expect(info.capabilities.canSign).toBe(true);
|
||||
expect(info.capabilities.canEncrypt).toBe(true);
|
||||
});
|
||||
|
||||
it('returns serialNumber as hex', async () => {
|
||||
const info = await extractCertificateInfo(testCert, testCertDer);
|
||||
// Serial number 1 → should be hex string
|
||||
expect(info.serialNumber).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
// Each test file gets a fresh global indexedDB via fake-indexeddb/auto.
|
||||
// Since openDB() caches connections implicitly, we re-import the module for each test.
|
||||
// However, to keep it simple, we'll just test in order and accept cumulative state,
|
||||
// or we can test with unique IDs.
|
||||
|
||||
import {
|
||||
saveKeyRecord,
|
||||
getKeyRecord,
|
||||
getKeyRecordForEmail,
|
||||
listKeyRecords,
|
||||
deleteKeyRecord,
|
||||
savePublicCert,
|
||||
getPublicCertForEmail,
|
||||
listPublicCerts,
|
||||
deletePublicCert,
|
||||
} from '../key-storage';
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from '../types';
|
||||
|
||||
function makeKeyRecord(overrides: Partial<SmimeKeyRecord> = {}): SmimeKeyRecord {
|
||||
return {
|
||||
id: 'key-1',
|
||||
email: 'user@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
certificateChain: [],
|
||||
encryptedPrivateKey: new ArrayBuffer(32),
|
||||
salt: new ArrayBuffer(16),
|
||||
iv: new ArrayBuffer(12),
|
||||
kdfIterations: 600000,
|
||||
issuer: 'CN=Test CA',
|
||||
subject: 'CN=Test User',
|
||||
serialNumber: '01',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'aa:bb:cc',
|
||||
algorithm: 'RSA-2048',
|
||||
capabilities: { canSign: true, canEncrypt: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicCert(overrides: Partial<SmimePublicCert> = {}): SmimePublicCert {
|
||||
return {
|
||||
id: 'cert-1',
|
||||
email: 'recipient@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: 'CN=Test CA',
|
||||
subject: 'CN=Recipient',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'dd:ee:ff',
|
||||
source: 'manual',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Use unique IDs for each test to avoid state leakage
|
||||
let testCounter = 0;
|
||||
function uid() { return `test-${++testCounter}-${Date.now()}`; }
|
||||
|
||||
describe('key-storage', () => {
|
||||
describe('key records', () => {
|
||||
it('saves and retrieves a key record by id', async () => {
|
||||
const id = uid();
|
||||
const record = makeKeyRecord({ id });
|
||||
await saveKeyRecord(record);
|
||||
const retrieved = await getKeyRecord(id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.id).toBe(id);
|
||||
expect(retrieved!.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent key record', async () => {
|
||||
const result = await getKeyRecord('absolutely-non-existent-' + uid());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retrieves key record by email', async () => {
|
||||
const id = uid();
|
||||
const email = `alice-${id}@example.com`;
|
||||
const record = makeKeyRecord({ id, email });
|
||||
await saveKeyRecord(record);
|
||||
const result = await getKeyRecordForEmail(email);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.email).toBe(email);
|
||||
});
|
||||
|
||||
it('lists key records (includes previously saved)', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@example.com` }));
|
||||
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@example.com` }));
|
||||
const records = await listKeyRecords();
|
||||
expect(records.length).toBeGreaterThanOrEqual(2);
|
||||
expect(records.find(r => r.id === id1)).toBeDefined();
|
||||
expect(records.find(r => r.id === id2)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes a key record', async () => {
|
||||
const id = uid();
|
||||
const record = makeKeyRecord({ id });
|
||||
await saveKeyRecord(record);
|
||||
await deleteKeyRecord(id);
|
||||
const result = await getKeyRecord(id);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates existing record with same id', async () => {
|
||||
const id = uid();
|
||||
const record1 = makeKeyRecord({ id, email: 'old@example.com' });
|
||||
await saveKeyRecord(record1);
|
||||
const record2 = makeKeyRecord({ id, email: 'new@example.com' });
|
||||
await saveKeyRecord(record2);
|
||||
const retrieved = await getKeyRecord(id);
|
||||
expect(retrieved!.email).toBe('new@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public certs', () => {
|
||||
it('saves and retrieves by email', async () => {
|
||||
const id = uid();
|
||||
const email = `recipient-${id}@example.com`;
|
||||
const cert = makePublicCert({ id, email });
|
||||
await savePublicCert(cert);
|
||||
const result = await getPublicCertForEmail(email);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.email).toBe(email);
|
||||
});
|
||||
|
||||
it('lists public certs (includes previously saved)', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@test.com` }));
|
||||
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@test.com` }));
|
||||
const certs = await listPublicCerts();
|
||||
expect(certs.find(c => c.id === id1)).toBeDefined();
|
||||
expect(certs.find(c => c.id === id2)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes a public cert', async () => {
|
||||
const id = uid();
|
||||
const cert = makePublicCert({ id });
|
||||
await savePublicCert(cert);
|
||||
await deletePublicCert(id);
|
||||
const certs = await listPublicCerts();
|
||||
expect(certs.find(c => c.id === id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildMimeMessage, quotedPrintableEncode, base64Encode } from '../mime-builder';
|
||||
|
||||
// Mock crypto.randomUUID and crypto.getRandomValues for deterministic tests
|
||||
beforeEach(() => {
|
||||
let uuidCounter = 0;
|
||||
vi.spyOn(crypto, 'randomUUID').mockImplementation(
|
||||
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, '0')}` as `${string}-${string}-${string}-${string}-${string}`,
|
||||
);
|
||||
|
||||
vi.spyOn(crypto, 'getRandomValues').mockImplementation(<T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array) {
|
||||
const u8 = new Uint8Array((array as unknown as Uint8Array).buffer);
|
||||
for (let i = 0; i < u8.length; i++) u8[i] = i;
|
||||
}
|
||||
return array;
|
||||
});
|
||||
});
|
||||
|
||||
describe('mime-builder', () => {
|
||||
describe('buildMimeMessage', () => {
|
||||
it('builds a text-only message', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { name: 'Alice', email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Hello',
|
||||
textBody: 'Hi Bob!',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('From: "Alice" <alice@example.com>');
|
||||
expect(text).toContain('To: bob@example.com');
|
||||
expect(text).toContain('Subject: Hello');
|
||||
expect(text).toContain('Content-Type: text/plain; charset=utf-8');
|
||||
expect(text).toContain('MIME-Version: 1.0');
|
||||
expect(text).toContain('Hi Bob!');
|
||||
});
|
||||
|
||||
it('builds a text + HTML multipart/alternative', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Test',
|
||||
textBody: 'Plain text',
|
||||
htmlBody: '<p>HTML body</p>',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: multipart/alternative');
|
||||
expect(text).toContain('Content-Type: text/plain; charset=utf-8');
|
||||
expect(text).toContain('Content-Type: text/html; charset=utf-8');
|
||||
expect(text).toContain('Plain text');
|
||||
expect(text).toContain('<p>HTML body</p>');
|
||||
});
|
||||
|
||||
it('builds HTML-only message', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'HTML only',
|
||||
htmlBody: '<h1>Hello</h1>',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: text/html; charset=utf-8');
|
||||
expect(text).toContain('<h1>Hello</h1>');
|
||||
});
|
||||
|
||||
it('builds message with attachments', () => {
|
||||
const attachment = {
|
||||
filename: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
content: new TextEncoder().encode('file content').buffer,
|
||||
};
|
||||
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'With attachment',
|
||||
textBody: 'See attached',
|
||||
attachments: [attachment],
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: multipart/mixed');
|
||||
expect(text).toContain('Content-Disposition: attachment; filename="test.txt"');
|
||||
expect(text).toContain('Content-Transfer-Encoding: base64');
|
||||
});
|
||||
|
||||
it('builds message with inline attachment (cid)', () => {
|
||||
const inline = {
|
||||
filename: 'image.png',
|
||||
contentType: 'image/png',
|
||||
content: new Uint8Array([0x89, 0x50, 0x4E, 0x47]).buffer,
|
||||
cid: 'img1',
|
||||
};
|
||||
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Inline',
|
||||
htmlBody: '<img src="cid:img1">',
|
||||
attachments: [inline],
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Disposition: inline; filename="image.png"');
|
||||
expect(text).toContain('Content-ID: <img1>');
|
||||
});
|
||||
|
||||
it('includes CC header when provided', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
cc: [{ name: 'Charlie', email: 'charlie@example.com' }],
|
||||
subject: 'CC test',
|
||||
textBody: 'Hello',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Cc: "Charlie" <charlie@example.com>');
|
||||
});
|
||||
|
||||
it('omits BCC from MIME headers', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
bcc: [{ email: 'secret@example.com' }],
|
||||
subject: 'BCC test',
|
||||
textBody: 'Hello',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).not.toContain('Bcc');
|
||||
expect(text).not.toContain('secret@example.com');
|
||||
});
|
||||
|
||||
it('includes In-Reply-To and References', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Re: Thread',
|
||||
textBody: 'reply',
|
||||
inReplyTo: '<msg1@example.com>',
|
||||
references: ['<msg0@example.com>', '<msg1@example.com>'],
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('In-Reply-To: <msg1@example.com>');
|
||||
expect(text).toContain('References: <msg0@example.com> <msg1@example.com>');
|
||||
});
|
||||
|
||||
it('encodes non-ASCII subject with RFC 2047', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Ünïcödé',
|
||||
textBody: 'test',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('=?UTF-8?Q?');
|
||||
});
|
||||
|
||||
it('uses CRLF line endings', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'CRLF',
|
||||
textBody: 'test',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
// Should contain CRLF before the body
|
||||
expect(text).toContain('\r\n');
|
||||
// Should not contain bare LF without preceding CR (except within QP encoding)
|
||||
const lines = text.split('\r\n');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('builds empty body message', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Empty',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('escapes display name in From header', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { name: 'O\'Brien, "Bob"', email: 'bob@example.com' },
|
||||
to: [{ email: 'alice@example.com' }],
|
||||
subject: 'Name test',
|
||||
textBody: 'test',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('From: "O\'Brien, \\"Bob\\"" <bob@example.com>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quotedPrintableEncode', () => {
|
||||
it('passes through ASCII text unchanged', () => {
|
||||
const result = quotedPrintableEncode('Hello World');
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('encodes non-ASCII characters', () => {
|
||||
const result = quotedPrintableEncode('Héllo');
|
||||
expect(result).toContain('=');
|
||||
});
|
||||
|
||||
it('encodes equals sign', () => {
|
||||
const result = quotedPrintableEncode('a=b');
|
||||
expect(result).toContain('=3D');
|
||||
});
|
||||
|
||||
it('wraps long lines with soft line break', () => {
|
||||
const longLine = 'a'.repeat(100);
|
||||
const result = quotedPrintableEncode(longLine);
|
||||
const lines = result.split('\r\n');
|
||||
for (const line of lines) {
|
||||
expect(line.length).toBeLessThanOrEqual(76);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64Encode', () => {
|
||||
it('encodes binary data to base64', () => {
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]).buffer; // "Hello"
|
||||
const result = base64Encode(data);
|
||||
expect(result).toBe('SGVsbG8=');
|
||||
});
|
||||
|
||||
it('wraps long lines at 76 chars', () => {
|
||||
const data = new Uint8Array(200).buffer;
|
||||
const result = base64Encode(data);
|
||||
const lines = result.split('\r\n');
|
||||
for (const line of lines) {
|
||||
expect(line.length).toBeLessThanOrEqual(76);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { importPkcs12, unlockPrivateKey, decryptPrivateKeyBytes } from '../pkcs12-import';
|
||||
import { exportPkcs12 } from '../pkcs12-export';
|
||||
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
function stringToAB(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal real PKCS#12 (.p12) blob for testing.
|
||||
*/
|
||||
async function buildTestP12(
|
||||
email: string,
|
||||
cn: string,
|
||||
p12Password: string,
|
||||
): Promise<{ p12Bytes: ArrayBuffer; keyPair: globalThis.CryptoKeyPair; certDer: ArrayBuffer }> {
|
||||
// Generate RSA key pair (signing)
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// Self-signed certificate
|
||||
const cert = new pkijs.Certificate();
|
||||
cert.version = 2;
|
||||
cert.serialNumber = new asn1js.Integer({ value: 42 });
|
||||
|
||||
cert.issuer.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '1.2.840.113549.1.9.1',
|
||||
value: new asn1js.IA5String({ value: email }),
|
||||
}),
|
||||
);
|
||||
cert.notBefore.value = new Date('2024-01-01T00:00:00Z');
|
||||
cert.notAfter.value = new Date('2030-12-31T23:59:59Z');
|
||||
|
||||
await cert.subjectPublicKeyInfo.importKey(keyPair.publicKey, cryptoEngine);
|
||||
await cert.sign(keyPair.privateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
const certDer = cert.toSchema(true).toBER(false);
|
||||
|
||||
// Export private key as PKCS#8
|
||||
const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
|
||||
// Build PKCS#12 structure
|
||||
const keyBag = new pkijs.PKCS8ShroudedKeyBag({
|
||||
parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes),
|
||||
});
|
||||
|
||||
const passwordBuf = stringToAB(p12Password);
|
||||
|
||||
await keyBag.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
contentEncryptionAlgorithm: {
|
||||
name: 'AES-CBC',
|
||||
length: 256,
|
||||
} as Parameters<typeof keyBag.makeInternalValues>[0]['contentEncryptionAlgorithm'],
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
iterationCount: 2048,
|
||||
});
|
||||
|
||||
const keyBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.2',
|
||||
bagValue: keyBag,
|
||||
});
|
||||
|
||||
const certBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3',
|
||||
bagValue: new pkijs.CertBag({ parsedValue: cert }),
|
||||
});
|
||||
|
||||
const authenticatedSafe = new pkijs.AuthenticatedSafe({
|
||||
parsedValue: {
|
||||
safeContents: [
|
||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [keyBagSafe] }) },
|
||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [certBagSafe] }) },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await authenticatedSafe.makeInternalValues({ safeContents: [{}, {}] });
|
||||
|
||||
const pfx = new pkijs.PFX({
|
||||
parsedValue: {
|
||||
integrityMode: 0,
|
||||
authenticatedSafe,
|
||||
},
|
||||
});
|
||||
|
||||
await pfx.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
iterations: 2048,
|
||||
pbkdf2HashAlgorithm: 'SHA-256',
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
});
|
||||
|
||||
const p12Bytes = pfx.toSchema().toBER(false);
|
||||
return { p12Bytes, keyPair, certDer };
|
||||
}
|
||||
|
||||
let testP12: Awaited<ReturnType<typeof buildTestP12>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
testP12 = await buildTestP12('alice@example.com', 'Alice Test', 'p12pass');
|
||||
});
|
||||
|
||||
describe('importPkcs12', () => {
|
||||
it('imports a valid PKCS#12 file and produces a key record', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
|
||||
expect(result.keyRecord).toBeDefined();
|
||||
expect(result.keyRecord.email).toBe('alice@example.com');
|
||||
expect(result.keyRecord.subject).toContain('Alice Test');
|
||||
expect(result.keyRecord.certificate).toBeDefined();
|
||||
expect(result.keyRecord.encryptedPrivateKey.byteLength).toBeGreaterThan(0);
|
||||
expect(result.keyRecord.salt.byteLength).toBeGreaterThan(0);
|
||||
expect(result.keyRecord.iv.byteLength).toBeGreaterThan(0);
|
||||
expect(result.keyRecord.kdfIterations).toBe(600_000);
|
||||
expect(result.keyRecord.fingerprint).toBeTruthy();
|
||||
|
||||
expect(result.certInfo).toBeDefined();
|
||||
expect(result.certInfo.emailAddresses).toContain('alice@example.com');
|
||||
});
|
||||
|
||||
it('throws on invalid ASN.1 data', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
await expect(importPkcs12(garbage, 'pass', 'store')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockPrivateKey', () => {
|
||||
it('unlocks and returns signing and decryption keys', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
const { signingKey, decryptionKey } = await unlockPrivateKey(result.keyRecord, 'storagepass');
|
||||
|
||||
expect(signingKey).toBeDefined();
|
||||
expect(signingKey.type).toBe('private');
|
||||
expect(signingKey.extractable).toBe(false);
|
||||
|
||||
expect(decryptionKey).toBeDefined();
|
||||
expect(decryptionKey!.type).toBe('private');
|
||||
expect(decryptionKey!.extractable).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on incorrect passphrase', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
await expect(unlockPrivateKey(result.keyRecord, 'wrongpass')).rejects.toThrow('Incorrect passphrase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptPrivateKeyBytes', () => {
|
||||
it('returns raw PKCS#8 bytes', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
const pkcs8 = await decryptPrivateKeyBytes(result.keyRecord, 'storagepass');
|
||||
|
||||
expect(pkcs8).toBeInstanceOf(ArrayBuffer);
|
||||
expect(pkcs8.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws on incorrect passphrase', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
await expect(decryptPrivateKeyBytes(result.keyRecord, 'bad')).rejects.toThrow('Incorrect passphrase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportPkcs12', () => {
|
||||
it('produces a valid PKCS#12 that can be re-imported', async () => {
|
||||
const imported = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
|
||||
// Export
|
||||
const p12Out = await exportPkcs12(imported.keyRecord, 'storagepass', 'exportpass');
|
||||
expect(p12Out).toBeInstanceOf(ArrayBuffer);
|
||||
expect(p12Out.byteLength).toBeGreaterThan(0);
|
||||
|
||||
// Re-import
|
||||
const reimported = await importPkcs12(p12Out, 'exportpass', 'newstoragepass');
|
||||
expect(reimported.keyRecord.email).toBe('alice@example.com');
|
||||
expect(reimported.keyRecord.subject).toContain('Alice Test');
|
||||
expect(reimported.keyRecord.fingerprint).toBe(imported.keyRecord.fingerprint);
|
||||
});
|
||||
|
||||
it('throws on incorrect storage passphrase', async () => {
|
||||
const imported = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
await expect(exportPkcs12(imported.keyRecord, 'wrong', 'exportpass')).rejects.toThrow('Incorrect passphrase');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { smimeSign } from '../smime-sign';
|
||||
import { smimeEncrypt } from '../smime-encrypt';
|
||||
import { smimeDecrypt, SmimeKeyLockedError, findDecryptionCandidates, normalizeCmsBytes } from '../smime-decrypt';
|
||||
import { smimeVerify } from '../smime-verify';
|
||||
import { extractCertificateInfo } from '../certificate-utils';
|
||||
import type { SmimeKeyRecord } from '../types';
|
||||
|
||||
/**
|
||||
* Integration tests for S/MIME sign→verify and encrypt→decrypt roundtrips.
|
||||
* Uses Node.js crypto (not jsdom) for accurate Web Crypto behavior.
|
||||
*/
|
||||
|
||||
const testMimeBytes = new TextEncoder().encode(
|
||||
'Content-Type: text/plain; charset=utf-8\r\n\r\nHello, World!',
|
||||
);
|
||||
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
async function buildCert(
|
||||
cn: string,
|
||||
email: string,
|
||||
publicKey: CryptoKey,
|
||||
signingPrivateKey: CryptoKey,
|
||||
): Promise<{ cert: pkijs.Certificate; certDer: ArrayBuffer }> {
|
||||
const cert = new pkijs.Certificate();
|
||||
cert.version = 2;
|
||||
cert.serialNumber = new asn1js.Integer({ value: Math.floor(Math.random() * 100000) });
|
||||
|
||||
cert.issuer.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '1.2.840.113549.1.9.1',
|
||||
value: new asn1js.IA5String({ value: email }),
|
||||
}),
|
||||
);
|
||||
cert.notBefore.value = new Date('2024-01-01T00:00:00Z');
|
||||
cert.notAfter.value = new Date('2030-12-31T23:59:59Z');
|
||||
|
||||
await cert.subjectPublicKeyInfo.importKey(publicKey, cryptoEngine);
|
||||
|
||||
await cert.sign(signingPrivateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
const certDer = cert.toSchema(true).toBER(false);
|
||||
return { cert, certDer };
|
||||
}
|
||||
|
||||
async function makeKeyRecord(
|
||||
id: string,
|
||||
email: string,
|
||||
certDer: ArrayBuffer,
|
||||
): Promise<SmimeKeyRecord> {
|
||||
const cert = new pkijs.Certificate({
|
||||
schema: asn1js.fromBER(certDer).result,
|
||||
});
|
||||
const info = await extractCertificateInfo(cert, certDer);
|
||||
return {
|
||||
id,
|
||||
email: email.toLowerCase(),
|
||||
certificate: certDer,
|
||||
certificateChain: [],
|
||||
encryptedPrivateKey: new ArrayBuffer(0),
|
||||
salt: new ArrayBuffer(0),
|
||||
iv: new ArrayBuffer(0),
|
||||
kdfIterations: 600000,
|
||||
issuer: info.issuer,
|
||||
subject: info.subject,
|
||||
serialNumber: info.serialNumber,
|
||||
notBefore: info.notBefore,
|
||||
notAfter: info.notAfter,
|
||||
fingerprint: info.fingerprint,
|
||||
algorithm: info.algorithm,
|
||||
capabilities: info.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
// Signing key pair and cert (RSASSA-PKCS1-v1_5 public key embedded in cert)
|
||||
let signKeyPair: globalThis.CryptoKeyPair;
|
||||
let signCertDer: ArrayBuffer;
|
||||
|
||||
// Encryption key pair and cert (RSA-OAEP public key embedded in cert)
|
||||
let encKeyPair: globalThis.CryptoKeyPair;
|
||||
let encCertDer: ArrayBuffer;
|
||||
let encKeyRecord: SmimeKeyRecord;
|
||||
|
||||
// Second encryption identity for cross-recipient tests
|
||||
let bobEncKeyPair: globalThis.CryptoKeyPair;
|
||||
let bobEncCertDer: ArrayBuffer;
|
||||
let bobKeyRecord: SmimeKeyRecord;
|
||||
|
||||
beforeAll(async () => {
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
|
||||
// --- Signing identity ---
|
||||
signKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
const signResult = await buildCert('Alice Signer', 'alice@example.com', signKeyPair.publicKey, signKeyPair.privateKey);
|
||||
signCertDer = signResult.certDer;
|
||||
|
||||
// --- Encryption identity (Alice) ---
|
||||
encKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'],
|
||||
);
|
||||
// Self-sign with a temporary signing key
|
||||
const tempSignKey = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
const encResult = await buildCert('Alice', 'alice@example.com', encKeyPair.publicKey, tempSignKey.privateKey);
|
||||
encCertDer = encResult.certDer;
|
||||
encKeyRecord = await makeKeyRecord('key-alice-enc', 'alice@example.com', encCertDer);
|
||||
|
||||
// --- Bob encryption identity ---
|
||||
bobEncKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'],
|
||||
);
|
||||
const bobTempSignKey = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
const bobResult = await buildCert('Bob', 'bob@example.com', bobEncKeyPair.publicKey, bobTempSignKey.privateKey);
|
||||
bobEncCertDer = bobResult.certDer;
|
||||
bobKeyRecord = await makeKeyRecord('key-bob-enc', 'bob@example.com', bobEncCertDer);
|
||||
});
|
||||
|
||||
describe('smimeSign + smimeVerify roundtrip', () => {
|
||||
it('signs and verifies a message successfully', async () => {
|
||||
const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer);
|
||||
expect(signedBlob).toBeInstanceOf(Blob);
|
||||
expect(signedBlob.type).toContain('application/pkcs7-mime');
|
||||
|
||||
const cmsBytes = await signedBlob.arrayBuffer();
|
||||
const result = await smimeVerify(cmsBytes, 'alice@example.com');
|
||||
|
||||
expect(result.status.isSigned).toBe(true);
|
||||
expect(result.status.signatureValid).toBe(true);
|
||||
expect(result.status.signerEmailMatch).toBe(true);
|
||||
expect(result.status.signerCert).toBeDefined();
|
||||
expect(result.status.signerCert!.email).toBe('alice@example.com');
|
||||
|
||||
const innerText = new TextDecoder().decode(result.mimeBytes);
|
||||
expect(innerText).toContain('Hello, World!');
|
||||
});
|
||||
|
||||
it('reports email mismatch when From differs from signer', async () => {
|
||||
const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer);
|
||||
const cmsBytes = await signedBlob.arrayBuffer();
|
||||
const result = await smimeVerify(cmsBytes, 'evil@attacker.com');
|
||||
|
||||
expect(result.status.isSigned).toBe(true);
|
||||
expect(result.status.signerEmailMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smimeEncrypt + smimeDecrypt roundtrip', () => {
|
||||
it('encrypts and decrypts a message', async () => {
|
||||
const encryptedBlob = await smimeEncrypt(
|
||||
testMimeBytes,
|
||||
[encCertDer],
|
||||
encCertDer,
|
||||
);
|
||||
expect(encryptedBlob).toBeInstanceOf(Blob);
|
||||
expect(encryptedBlob.type).toContain('application/pkcs7-mime');
|
||||
|
||||
const cmsBytes = await encryptedBlob.arrayBuffer();
|
||||
const unlockedKeys = new Map<string, CryptoKey>();
|
||||
unlockedKeys.set(encKeyRecord.id, encKeyPair.privateKey);
|
||||
|
||||
const result = await smimeDecrypt({
|
||||
cmsBytes,
|
||||
keyRecords: [encKeyRecord],
|
||||
unlockedKeys,
|
||||
});
|
||||
|
||||
expect(result.mimeBytes).toBeDefined();
|
||||
const decryptedText = new TextDecoder().decode(result.mimeBytes);
|
||||
expect(decryptedText).toContain('Hello, World!');
|
||||
expect(result.keyRecordId).toBe(encKeyRecord.id);
|
||||
});
|
||||
|
||||
it('throws when no matching key is available', async () => {
|
||||
const encryptedBlob = await smimeEncrypt(
|
||||
testMimeBytes,
|
||||
[encCertDer],
|
||||
encCertDer,
|
||||
);
|
||||
const cmsBytes = await encryptedBlob.arrayBuffer();
|
||||
|
||||
// Bob's key record doesn't match Alice's encrypted message
|
||||
await expect(
|
||||
smimeDecrypt({
|
||||
cmsBytes,
|
||||
keyRecords: [bobKeyRecord],
|
||||
unlockedKeys: new Map(),
|
||||
}),
|
||||
).rejects.toThrow('No imported S/MIME key matches');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmimeKeyLockedError', () => {
|
||||
it('has correct name and keyRecordId', () => {
|
||||
const err = new SmimeKeyLockedError('test', 'key-1');
|
||||
expect(err.name).toBe('SmimeKeyLockedError');
|
||||
expect(err.keyRecordId).toBe('key-1');
|
||||
expect(err.message).toBe('test');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDecryptionCandidates', () => {
|
||||
it('returns empty array for invalid CMS data', () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
const result = findDecryptionCandidates(garbage, [encKeyRecord]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smimeVerify edge cases', () => {
|
||||
it('throws on invalid ASN.1 data', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
await expect(smimeVerify(garbage)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCmsBytes', () => {
|
||||
// Helper: a minimal DER-encoded ASN.1 SEQUENCE (0x30 tag)
|
||||
const derBytes = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]);
|
||||
|
||||
it('passes through raw DER unchanged', () => {
|
||||
const result = new Uint8Array(normalizeCmsBytes(derBytes.buffer as ArrayBuffer));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('passes through empty buffer unchanged', () => {
|
||||
const result = normalizeCmsBytes(new ArrayBuffer(0));
|
||||
expect(result.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it('decodes plain base64 content', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const input = new TextEncoder().encode(b64).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes base64 content with MIME headers', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const mime =
|
||||
'Content-Type: application/pkcs7-mime\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n' +
|
||||
'\r\n' +
|
||||
b64 + '\r\n';
|
||||
const input = new TextEncoder().encode(mime).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes PEM-wrapped content', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const pem = '-----BEGIN PKCS7-----\n' + b64 + '\n-----END PKCS7-----\n';
|
||||
const input = new TextEncoder().encode(pem).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes MIME headers with unix line endings', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const mime =
|
||||
'Content-Type: application/pkcs7-mime\n' +
|
||||
'Content-Transfer-Encoding: base64\n' +
|
||||
'\n' +
|
||||
b64 + '\n';
|
||||
const input = new TextEncoder().encode(mime).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes base64 when MIME headers are very long', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const longHeader = 'X-Long-Header: ' + 'A'.repeat(3000) + '\r\n';
|
||||
const mime =
|
||||
longHeader +
|
||||
'Content-Type: application/pkcs7-mime\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n' +
|
||||
'\r\n' +
|
||||
b64 + '\r\n';
|
||||
const input = new TextEncoder().encode(mime).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('extracts largest base64 block from multipart-like text', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const multipartLike =
|
||||
'Content-Type: multipart/mixed; boundary="b"\r\n\r\n' +
|
||||
'--b\r\n' +
|
||||
'Content-Type: text/plain\r\n\r\n' +
|
||||
'hello\r\n' +
|
||||
'--b\r\n' +
|
||||
'Content-Type: application/pkcs7-mime\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n\r\n' +
|
||||
b64 + '\r\n' +
|
||||
'--b--\r\n';
|
||||
const input = new TextEncoder().encode(multipartLike).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('returns original when content is not decodable', () => {
|
||||
const garbage = new Uint8Array([0x01, 0x02, 0xFF, 0xFE]);
|
||||
const result = normalizeCmsBytes(garbage.buffer as ArrayBuffer);
|
||||
// Should return original since it can\'t be decoded
|
||||
expect(result.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectSmime } from '../smime-detect';
|
||||
|
||||
describe('detectSmime', () => {
|
||||
describe('no S/MIME content', () => {
|
||||
it('returns null type when no arguments provided', () => {
|
||||
const result = detectSmime();
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null type for plain text content', () => {
|
||||
const result = detectSmime('text/plain');
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null type for multipart/mixed without S/MIME', () => {
|
||||
const result = detectSmime('multipart/mixed; boundary="abc"');
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content-Type header detection', () => {
|
||||
it('detects enveloped-data from Content-Type', () => {
|
||||
const ct = 'application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"';
|
||||
const body = { partId: '1', blobId: 'blob1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('blob1');
|
||||
expect(result.partId).toBe('1');
|
||||
});
|
||||
|
||||
it('detects signed-data from Content-Type', () => {
|
||||
const ct = 'application/pkcs7-mime; smime-type=signed-data; name="smime.p7m"';
|
||||
const body = { partId: '2', blobId: 'blob2', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('signed-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('blob2');
|
||||
});
|
||||
|
||||
it('detects x-pkcs7-mime variant', () => {
|
||||
const ct = 'application/x-pkcs7-mime; smime-type=enveloped-data';
|
||||
const body = { partId: '1', blobId: 'blob1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
|
||||
it('detects detached signature via multipart/signed', () => {
|
||||
const ct = 'multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256';
|
||||
const result = detectSmime(ct);
|
||||
expect(result.type).toBe('detached-sig');
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('handles generic pkcs7-mime without smime-type', () => {
|
||||
const ct = 'application/pkcs7-mime; name="smime.p7m"';
|
||||
const body = { partId: '1', blobId: 'blob1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
// Should default to enveloped-data for generic pkcs7-mime
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.blobId).toBe('blob1');
|
||||
});
|
||||
|
||||
it('is case-insensitive for Content-Type', () => {
|
||||
const ct = 'Application/PKCS7-MIME; smime-type=Enveloped-Data';
|
||||
const body = { partId: '1', blobId: 'b1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyStructure detection', () => {
|
||||
it('finds pkcs7-mime part in bodyStructure tree', () => {
|
||||
const body = {
|
||||
type: 'multipart/mixed',
|
||||
subParts: [
|
||||
{ partId: '1', type: 'text/plain', blobId: 'text-blob' },
|
||||
{
|
||||
partId: '2',
|
||||
type: 'application/pkcs7-mime; smime-type=enveloped-data',
|
||||
blobId: 'cms-blob',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = detectSmime(undefined, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('cms-blob');
|
||||
expect(result.partId).toBe('2');
|
||||
});
|
||||
|
||||
it('detects detached sig in multipart/signed bodyStructure', () => {
|
||||
const body = {
|
||||
type: 'multipart/signed',
|
||||
subParts: [
|
||||
{ partId: '1', type: 'text/plain', blobId: 'text-blob' },
|
||||
{ partId: '2', type: 'application/pkcs7-signature', blobId: 'sig-blob' },
|
||||
],
|
||||
};
|
||||
const result = detectSmime(undefined, body);
|
||||
expect(result.type).toBe('detached-sig');
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('walks nested bodyStructure', () => {
|
||||
const body = {
|
||||
type: 'multipart/mixed',
|
||||
subParts: [
|
||||
{
|
||||
type: 'multipart/alternative',
|
||||
subParts: [
|
||||
{ partId: '1.1', type: 'text/plain', blobId: 'txt' },
|
||||
{ partId: '1.2', type: 'text/html', blobId: 'html' },
|
||||
],
|
||||
},
|
||||
{
|
||||
partId: '2',
|
||||
type: 'application/pkcs7-mime; smime-type=signed-data',
|
||||
blobId: 'sig-blob',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = detectSmime(undefined, body);
|
||||
expect(result.type).toBe('signed-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('sig-blob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment detection', () => {
|
||||
it('detects .p7m attachment', () => {
|
||||
const attachments = [
|
||||
{ partId: '3', blobId: 'att-blob', name: 'message.p7m', type: 'application/octet-stream' },
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('att-blob');
|
||||
});
|
||||
|
||||
it('detects .p7s attachment as detached-sig', () => {
|
||||
const attachments = [
|
||||
{ partId: '3', blobId: 'sig-blob', name: 'smime.p7s', type: 'application/octet-stream' },
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBe('detached-sig');
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('detects pkcs7-mime attachment type', () => {
|
||||
const attachments = [
|
||||
{
|
||||
partId: '2',
|
||||
blobId: 'enc-blob',
|
||||
name: 'encrypted.bin',
|
||||
type: 'application/pkcs7-mime; smime-type=enveloped-data',
|
||||
},
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
|
||||
it('skips non-S/MIME attachments', () => {
|
||||
const attachments = [
|
||||
{ partId: '2', blobId: 'pdf-blob', name: 'document.pdf', type: 'application/pdf' },
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority order', () => {
|
||||
it('Content-Type takes precedence over bodyStructure', () => {
|
||||
const ct = 'application/pkcs7-mime; smime-type=enveloped-data';
|
||||
const body = {
|
||||
partId: '1',
|
||||
blobId: 'from-ct',
|
||||
type: ct,
|
||||
};
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.blobId).toBe('from-ct');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock IndexedDB storage functions before importing store
|
||||
vi.mock('@/lib/smime/key-storage', () => ({
|
||||
saveKeyRecord: vi.fn().mockResolvedValue(undefined),
|
||||
listKeyRecords: vi.fn().mockResolvedValue([]),
|
||||
deleteKeyRecord: vi.fn().mockResolvedValue(undefined),
|
||||
savePublicCert: vi.fn().mockResolvedValue(undefined),
|
||||
listPublicCerts: vi.fn().mockResolvedValue([]),
|
||||
deletePublicCert: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/smime/pkcs12-import', () => ({
|
||||
importPkcs12: vi.fn(),
|
||||
unlockPrivateKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/smime/certificate-utils', () => ({
|
||||
parseCertificatePemOrDer: vi.fn(),
|
||||
extractCertificateInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
import { listKeyRecords, listPublicCerts, saveKeyRecord, deleteKeyRecord, savePublicCert, deletePublicCert } from '@/lib/smime/key-storage';
|
||||
import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import';
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
||||
|
||||
const mockKeyRecord: SmimeKeyRecord = {
|
||||
id: 'key-1',
|
||||
email: 'user@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
certificateChain: [],
|
||||
encryptedPrivateKey: new ArrayBuffer(32),
|
||||
salt: new ArrayBuffer(16),
|
||||
iv: new ArrayBuffer(12),
|
||||
kdfIterations: 600000,
|
||||
issuer: 'CN=Test CA',
|
||||
subject: 'CN=Test User',
|
||||
serialNumber: '01',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'aa:bb:cc',
|
||||
algorithm: 'RSA-2048',
|
||||
capabilities: { canSign: true, canEncrypt: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
// Reset store state
|
||||
useSmimeStore.setState({
|
||||
keyRecords: [],
|
||||
publicCerts: [],
|
||||
unlockedKeys: new Map(),
|
||||
unlockedDecryptionKeys: new Map(),
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('smime-store', () => {
|
||||
describe('load', () => {
|
||||
it('loads key records and public certs from IndexedDB', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
const certs: SmimePublicCert[] = [];
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue(certs);
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
const state = useSmimeStore.getState();
|
||||
expect(state.keyRecords).toEqual(records);
|
||||
expect(state.publicCerts).toEqual(certs);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('re-unlocks remembered keys during load', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
const mockDecryptionKey = {} as CryptoKey;
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({ rememberUnlockedKeys: true });
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({
|
||||
signingKey: mockSigningKey,
|
||||
decryptionKey: mockDecryptionKey,
|
||||
});
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(unlockPrivateKey).toHaveBeenCalledWith(mockKeyRecord, 'passphrase');
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
||||
});
|
||||
|
||||
it('removes stale remembered keys when re-unlock fails', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'bad-pass' }));
|
||||
useSmimeStore.setState({ rememberUnlockedKeys: true });
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
vi.mocked(unlockPrivateKey).mockRejectedValue(new Error('Incorrect passphrase'));
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
vi.mocked(listKeyRecords).mockRejectedValue(new Error('DB failed'));
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(useSmimeStore.getState().error).toBe('DB failed');
|
||||
expect(useSmimeStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPKCS12', () => {
|
||||
it('imports and adds key record', async () => {
|
||||
vi.mocked(importPkcs12).mockResolvedValue({
|
||||
keyRecord: mockKeyRecord,
|
||||
certInfo: {} as any,
|
||||
});
|
||||
|
||||
const result = await useSmimeStore.getState().importPKCS12(
|
||||
new ArrayBuffer(10),
|
||||
'p12pass',
|
||||
'storagepass',
|
||||
);
|
||||
|
||||
expect(result.id).toBe('key-1');
|
||||
expect(saveKeyRecord).toHaveBeenCalledWith(mockKeyRecord);
|
||||
expect(useSmimeStore.getState().keyRecords).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sets error on import failure', async () => {
|
||||
vi.mocked(importPkcs12).mockRejectedValue(new Error('Bad password'));
|
||||
|
||||
await expect(
|
||||
useSmimeStore.getState().importPKCS12(new ArrayBuffer(10), 'wrong', 'pass'),
|
||||
).rejects.toThrow('Bad password');
|
||||
|
||||
expect(useSmimeStore.getState().error).toBe('Bad password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeKeyRecord', () => {
|
||||
it('removes key record and clears bindings', async () => {
|
||||
useSmimeStore.setState({
|
||||
keyRecords: [mockKeyRecord],
|
||||
identityKeyBindings: { 'identity-1': 'key-1' },
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
await useSmimeStore.getState().removeKeyRecord('key-1');
|
||||
|
||||
expect(deleteKeyRecord).toHaveBeenCalledWith('key-1');
|
||||
expect(useSmimeStore.getState().keyRecords).toHaveLength(0);
|
||||
expect(useSmimeStore.getState().identityKeyBindings).toEqual({});
|
||||
expect(useSmimeStore.getState().unlockedKeys.has('key-1')).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePublicCert', () => {
|
||||
it('removes public cert', async () => {
|
||||
const cert: SmimePublicCert = {
|
||||
id: 'cert-1',
|
||||
email: 'recipient@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: 'CN=CA',
|
||||
subject: 'CN=Recipient',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'aa:bb',
|
||||
source: 'manual',
|
||||
};
|
||||
useSmimeStore.setState({ publicCerts: [cert] });
|
||||
|
||||
await useSmimeStore.getState().removePublicCert('cert-1');
|
||||
|
||||
expect(deletePublicCert).toHaveBeenCalledWith('cert-1');
|
||||
expect(useSmimeStore.getState().publicCerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockKey + lockKey', () => {
|
||||
it('unlocks a key', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
const mockDecryptionKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey, decryptionKey: mockDecryptionKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(true);
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
||||
});
|
||||
|
||||
it('stores the passphrase for session rehydration when remember is enabled', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord], rememberUnlockedKeys: true });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBe(
|
||||
JSON.stringify({ 'key-1': 'passphrase' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores only the signing key when no decryption key is available', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('throws for non-existent key record', async () => {
|
||||
await expect(
|
||||
useSmimeStore.getState().unlockKey('non-existent', 'pass'),
|
||||
).rejects.toThrow('Key record not found');
|
||||
});
|
||||
|
||||
it('locks a key', () => {
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().lockKey('key-1');
|
||||
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('locks all keys', () => {
|
||||
sessionStorage.setItem(
|
||||
'smime-unlocked-session',
|
||||
JSON.stringify({ 'key-1': 'one', 'key-2': 'two' }),
|
||||
);
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([
|
||||
['key-1', {} as CryptoKey],
|
||||
['key-2', {} as CryptoKey],
|
||||
]),
|
||||
unlockedDecryptionKeys: new Map([
|
||||
['key-1', {} as CryptoKey],
|
||||
['key-2', {} as CryptoKey],
|
||||
]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().lockAllKeys();
|
||||
|
||||
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.size).toBe(0);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity bindings', () => {
|
||||
it('binds an identity to a key', () => {
|
||||
useSmimeStore.getState().bindIdentityToKey('identity-1', 'key-1');
|
||||
expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBe('key-1');
|
||||
});
|
||||
|
||||
it('unbinds an identity', () => {
|
||||
useSmimeStore.setState({ identityKeyBindings: { 'identity-1': 'key-1' } });
|
||||
useSmimeStore.getState().bindIdentityToKey('identity-1', null);
|
||||
expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getKeyRecordForIdentity returns the bound record', () => {
|
||||
useSmimeStore.setState({
|
||||
keyRecords: [mockKeyRecord],
|
||||
identityKeyBindings: { 'identity-1': 'key-1' },
|
||||
});
|
||||
|
||||
const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-1');
|
||||
expect(record?.id).toBe('key-1');
|
||||
});
|
||||
|
||||
it('getKeyRecordForIdentity returns undefined for unbound identity', () => {
|
||||
const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-2');
|
||||
expect(record).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicCertForEmail', () => {
|
||||
it('finds cert by email (case-insensitive)', () => {
|
||||
const cert: SmimePublicCert = {
|
||||
id: 'c1',
|
||||
email: 'bob@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: 'CN=CA',
|
||||
subject: 'CN=Bob',
|
||||
notBefore: '2024-01-01',
|
||||
notAfter: '2030-12-31',
|
||||
fingerprint: 'ff',
|
||||
source: 'manual',
|
||||
};
|
||||
useSmimeStore.setState({ publicCerts: [cert] });
|
||||
|
||||
expect(useSmimeStore.getState().getPublicCertForEmail('Bob@Example.COM')?.id).toBe('c1');
|
||||
});
|
||||
|
||||
it('returns undefined when not found', () => {
|
||||
expect(useSmimeStore.getState().getPublicCertForEmail('nobody@test.com')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipientCerts', () => {
|
||||
it('partitions emails into found and missing', () => {
|
||||
const cert: SmimePublicCert = {
|
||||
id: 'c1',
|
||||
email: 'known@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: '',
|
||||
subject: '',
|
||||
notBefore: '',
|
||||
notAfter: '',
|
||||
fingerprint: '',
|
||||
source: 'manual',
|
||||
};
|
||||
useSmimeStore.setState({ publicCerts: [cert] });
|
||||
|
||||
const { found, missing } = useSmimeStore.getState().getRecipientCerts([
|
||||
'known@example.com',
|
||||
'unknown@example.com',
|
||||
]);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].id).toBe('c1');
|
||||
expect(missing).toEqual(['unknown@example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preferences', () => {
|
||||
it('sets sign default for identity', () => {
|
||||
useSmimeStore.getState().setSignDefault('identity-1', true);
|
||||
expect(useSmimeStore.getState().defaultSignIdentity['identity-1']).toBe(true);
|
||||
});
|
||||
|
||||
it('sets encrypt default', () => {
|
||||
useSmimeStore.getState().setEncryptDefault(true);
|
||||
expect(useSmimeStore.getState().defaultEncrypt).toBe(true);
|
||||
});
|
||||
|
||||
it('sets remember unlocked keys and clears when disabled', () => {
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().setRememberUnlockedKeys(false);
|
||||
|
||||
expect(useSmimeStore.getState().rememberUnlockedKeys).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets auto import signer certs', () => {
|
||||
useSmimeStore.getState().setAutoImportSignerCerts(true);
|
||||
expect(useSmimeStore.getState().autoImportSignerCerts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setError', () => {
|
||||
it('sets and clears error', () => {
|
||||
useSmimeStore.getState().setError('Something went wrong');
|
||||
expect(useSmimeStore.getState().error).toBe('Something went wrong');
|
||||
|
||||
useSmimeStore.getState().setError(null);
|
||||
expect(useSmimeStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { Convert } from 'pvtsutils';
|
||||
import type { CertificateInfo, SmimeKeyCapabilities } from './types';
|
||||
|
||||
/** OID for id-kp-emailProtection (S/MIME) */
|
||||
const OID_EMAIL_PROTECTION = '1.3.6.1.5.5.7.3.4';
|
||||
|
||||
/** OID for SubjectAlternativeName */
|
||||
const OID_SAN = '2.5.29.17';
|
||||
|
||||
// ── PEM/DER conversions ──────────────────────────────────────────────
|
||||
|
||||
export function pemToDer(pem: string): ArrayBuffer {
|
||||
const lines = pem
|
||||
.replace(/-----BEGIN [^-]+-----/, '')
|
||||
.replace(/-----END [^-]+-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
return Convert.FromBase64(lines);
|
||||
}
|
||||
|
||||
export function derToPem(der: ArrayBuffer, label: string): string {
|
||||
const b64 = Convert.ToBase64(der);
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < b64.length; i += 64) {
|
||||
lines.push(b64.slice(i, i + 64));
|
||||
}
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
export function isPem(data: string): boolean {
|
||||
return /-----BEGIN (CERTIFICATE|PKCS12|ENCRYPTED PRIVATE KEY|PRIVATE KEY)-----/.test(data);
|
||||
}
|
||||
|
||||
// ── Certificate parsing ──────────────────────────────────────────────
|
||||
|
||||
export function parseCertificateDer(der: ArrayBuffer): pkijs.Certificate {
|
||||
const asn1 = asn1js.fromBER(der);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid DER data: ASN.1 parsing failed');
|
||||
}
|
||||
return new pkijs.Certificate({ schema: asn1.result });
|
||||
}
|
||||
|
||||
export function parseCertificatePemOrDer(data: ArrayBuffer | string): pkijs.Certificate {
|
||||
if (typeof data === 'string') {
|
||||
if (isPem(data)) {
|
||||
return parseCertificateDer(pemToDer(data));
|
||||
}
|
||||
throw new Error('String input is not PEM-encoded');
|
||||
}
|
||||
// ArrayBuffer might contain PEM text rather than DER binary
|
||||
// PEM files start with "-----BEGIN " (0x2D 0x2D 0x2D 0x2D 0x2D 0x42)
|
||||
const header = new Uint8Array(data, 0, Math.min(20, data.byteLength));
|
||||
const maybePem = String.fromCharCode(...header);
|
||||
if (maybePem.startsWith('-----BEGIN ')) {
|
||||
const text = new TextDecoder().decode(data);
|
||||
return parseCertificateDer(pemToDer(text));
|
||||
}
|
||||
return parseCertificateDer(data);
|
||||
}
|
||||
|
||||
// ── Metadata extraction ──────────────────────────────────────────────
|
||||
|
||||
function rdnToString(rdn: pkijs.RelativeDistinguishedNames): string {
|
||||
return rdn.typesAndValues
|
||||
.map((tv) => {
|
||||
const oid = tv.type;
|
||||
const val = tv.value.valueBlock.value;
|
||||
const name = oidToName(oid);
|
||||
return `${name}=${val}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function oidToName(oid: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'2.5.4.3': 'CN',
|
||||
'2.5.4.6': 'C',
|
||||
'2.5.4.7': 'L',
|
||||
'2.5.4.8': 'ST',
|
||||
'2.5.4.10': 'O',
|
||||
'2.5.4.11': 'OU',
|
||||
'1.2.840.113549.1.9.1': 'E',
|
||||
};
|
||||
return map[oid] ?? oid;
|
||||
}
|
||||
|
||||
export async function computeFingerprint(der: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(der));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
function extractAlgorithm(cert: pkijs.Certificate): string {
|
||||
const algOid = cert.subjectPublicKeyInfo.algorithm.algorithmId;
|
||||
// RSA
|
||||
if (algOid === '1.2.840.113549.1.1.1') {
|
||||
const pubKey = cert.subjectPublicKeyInfo;
|
||||
try {
|
||||
const asn1Pub = asn1js.fromBER(pubKey.subjectPublicKey.valueBlock.valueHexView);
|
||||
const seq = asn1Pub.result as asn1js.Sequence;
|
||||
const modulus = seq.valueBlock.value[0] as asn1js.Integer;
|
||||
const bitLen = (modulus.valueBlock.valueHexView.byteLength - 1) * 8;
|
||||
return `RSA-${bitLen}`;
|
||||
} catch {
|
||||
return 'RSA';
|
||||
}
|
||||
}
|
||||
// ECDSA
|
||||
if (algOid === '1.2.840.10045.2.1') {
|
||||
const params = cert.subjectPublicKeyInfo.algorithm.algorithmParams;
|
||||
if (params instanceof asn1js.ObjectIdentifier) {
|
||||
const curveOid = params.valueBlock.toString();
|
||||
const curves: Record<string, string> = {
|
||||
'1.2.840.10045.3.1.7': 'ECDSA-P256',
|
||||
'1.3.132.0.34': 'ECDSA-P384',
|
||||
'1.3.132.0.35': 'ECDSA-P521',
|
||||
};
|
||||
return curves[curveOid] ?? 'ECDSA';
|
||||
}
|
||||
return 'ECDSA';
|
||||
}
|
||||
return algOid;
|
||||
}
|
||||
|
||||
function extractKeyUsage(cert: pkijs.Certificate): string[] | undefined {
|
||||
const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.15');
|
||||
if (!ext?.parsedValue) return undefined;
|
||||
const ku = ext.parsedValue as {
|
||||
digitalSignature?: boolean;
|
||||
contentCommitment?: boolean;
|
||||
keyEncipherment?: boolean;
|
||||
dataEncipherment?: boolean;
|
||||
keyAgreement?: boolean;
|
||||
keyCertSign?: boolean;
|
||||
cRLSign?: boolean;
|
||||
encipherOnly?: boolean;
|
||||
decipherOnly?: boolean;
|
||||
};
|
||||
const names: string[] = [];
|
||||
if (ku.digitalSignature) names.push('digitalSignature');
|
||||
if (ku.contentCommitment) names.push('contentCommitment');
|
||||
if (ku.keyEncipherment) names.push('keyEncipherment');
|
||||
if (ku.dataEncipherment) names.push('dataEncipherment');
|
||||
if (ku.keyAgreement) names.push('keyAgreement');
|
||||
if (ku.keyCertSign) names.push('keyCertSign');
|
||||
if (ku.cRLSign) names.push('cRLSign');
|
||||
if (ku.encipherOnly) names.push('encipherOnly');
|
||||
if (ku.decipherOnly) names.push('decipherOnly');
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractExtendedKeyUsage(cert: pkijs.Certificate): string[] | undefined {
|
||||
const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.37');
|
||||
if (!ext?.parsedValue) return undefined;
|
||||
const eku = ext.parsedValue as pkijs.ExtKeyUsage;
|
||||
return eku.keyPurposes;
|
||||
}
|
||||
|
||||
function extractEmailAddresses(cert: pkijs.Certificate): string[] {
|
||||
const emails: string[] = [];
|
||||
|
||||
// From subject emailAddress attribute
|
||||
for (const tv of cert.subject.typesAndValues) {
|
||||
if (tv.type === '1.2.840.113549.1.9.1') {
|
||||
emails.push(tv.value.valueBlock.value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// From SubjectAlternativeName
|
||||
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
||||
if (sanExt?.parsedValue) {
|
||||
const san = sanExt.parsedValue as pkijs.GeneralNames;
|
||||
for (const name of san.names) {
|
||||
// type 1 = rfc822Name
|
||||
if (name.type === 1 && typeof name.value === 'string') {
|
||||
if (!emails.includes(name.value)) {
|
||||
emails.push(name.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
/** Determine signing/encryption capabilities from KU / EKU. Tolerant of absent extensions. */
|
||||
export function classifyCapabilities(cert: pkijs.Certificate): SmimeKeyCapabilities {
|
||||
const ku = extractKeyUsage(cert);
|
||||
const eku = extractExtendedKeyUsage(cert);
|
||||
|
||||
let canSign = true;
|
||||
let canEncrypt = true;
|
||||
|
||||
// If KeyUsage is present, check explicit bits
|
||||
if (ku) {
|
||||
canSign = ku.includes('digitalSignature') || ku.includes('contentCommitment');
|
||||
canEncrypt = ku.includes('keyEncipherment') || ku.includes('dataEncipherment') || ku.includes('keyAgreement');
|
||||
}
|
||||
|
||||
// If EKU is present, only reject if it explicitly excludes emailProtection
|
||||
if (eku && eku.length > 0) {
|
||||
const hasEmailProtection = eku.includes(OID_EMAIL_PROTECTION);
|
||||
// Only restrict if EKU is present and does NOT include emailProtection
|
||||
if (!hasEmailProtection) {
|
||||
canSign = false;
|
||||
canEncrypt = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { canSign, canEncrypt };
|
||||
}
|
||||
|
||||
/** Extract full metadata from a parsed certificate. */
|
||||
export async function extractCertificateInfo(
|
||||
cert: pkijs.Certificate,
|
||||
der: ArrayBuffer,
|
||||
): Promise<CertificateInfo> {
|
||||
const fingerprint = await computeFingerprint(der);
|
||||
const ku = extractKeyUsage(cert);
|
||||
const eku = extractExtendedKeyUsage(cert);
|
||||
const capabilities = classifyCapabilities(cert);
|
||||
|
||||
return {
|
||||
subject: rdnToString(cert.subject),
|
||||
issuer: rdnToString(cert.issuer),
|
||||
serialNumber: cert.serialNumber.valueBlock.valueHexView
|
||||
? Array.from(new Uint8Array(cert.serialNumber.valueBlock.valueHexView))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(':')
|
||||
: cert.serialNumber.valueBlock.toString(),
|
||||
notBefore: cert.notBefore.value.toISOString(),
|
||||
notAfter: cert.notAfter.value.toISOString(),
|
||||
fingerprint,
|
||||
algorithm: extractAlgorithm(cert),
|
||||
keyUsage: ku,
|
||||
extendedKeyUsage: eku,
|
||||
emailAddresses: extractEmailAddresses(cert),
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
||||
|
||||
const DB_NAME = 'smime-store';
|
||||
const DB_VERSION = 1;
|
||||
const KEY_RECORDS_STORE = 'key-records';
|
||||
const PUBLIC_CERTS_STORE = 'public-certs';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
|
||||
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
||||
keyStore.createIndex('email', 'email', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
|
||||
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
||||
certStore.createIndex('email', 'email', { unique: false });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function txPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: globalThis.IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode);
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = fn(store);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Key record CRUD ─────────────────────────────────────────────────
|
||||
|
||||
export async function saveKeyRecord(record: SmimeKeyRecord): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.put(record));
|
||||
}
|
||||
|
||||
export async function getKeyRecord(id: string): Promise<SmimeKeyRecord | undefined> {
|
||||
const db = await openDB();
|
||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.get(id));
|
||||
}
|
||||
|
||||
export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecord | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(KEY_RECORDS_STORE, 'readonly');
|
||||
const idx = tx.objectStore(KEY_RECORDS_STORE).index('email');
|
||||
const req = idx.get(email.toLowerCase());
|
||||
req.onsuccess = () => resolve(req.result ?? undefined);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listKeyRecords(): Promise<SmimeKeyRecord[]> {
|
||||
const db = await openDB();
|
||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
||||
}
|
||||
|
||||
export async function deleteKeyRecord(id: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
// ── Public cert CRUD ────────────────────────────────────────────────
|
||||
|
||||
export async function savePublicCert(cert: SmimePublicCert): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.put(cert));
|
||||
}
|
||||
|
||||
export async function getPublicCertForEmail(email: string): Promise<SmimePublicCert | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(PUBLIC_CERTS_STORE, 'readonly');
|
||||
const idx = tx.objectStore(PUBLIC_CERTS_STORE).index('email');
|
||||
const req = idx.get(email.toLowerCase());
|
||||
req.onsuccess = () => resolve(req.result ?? undefined);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPublicCerts(): Promise<SmimePublicCert[]> {
|
||||
const db = await openDB();
|
||||
return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
||||
}
|
||||
|
||||
export async function deletePublicCert(id: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Minimal, deterministic MIME builder for outgoing S/MIME messages.
|
||||
*
|
||||
* Produces canonical text suitable for CMS signing/encryption.
|
||||
* All line endings are CRLF per RFC 5322.
|
||||
*/
|
||||
|
||||
const CRLF = '\r\n';
|
||||
|
||||
export interface MimeAttachment {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
content: ArrayBuffer;
|
||||
cid?: string; // for inline images
|
||||
}
|
||||
|
||||
export interface MimeMessageInput {
|
||||
from: { name?: string; email: string };
|
||||
to: { name?: string; email: string }[];
|
||||
cc?: { name?: string; email: string }[];
|
||||
bcc?: { name?: string; email: string }[];
|
||||
subject: string;
|
||||
date?: Date;
|
||||
messageId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
textBody?: string;
|
||||
htmlBody?: string;
|
||||
attachments?: MimeAttachment[];
|
||||
}
|
||||
|
||||
/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */
|
||||
export function buildMimeMessage(input: MimeMessageInput): Uint8Array {
|
||||
const boundary = generateBoundary();
|
||||
const lines: string[] = [];
|
||||
|
||||
// Headers
|
||||
lines.push(formatHeader('From', formatAddress(input.from)));
|
||||
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
|
||||
if (input.cc?.length) {
|
||||
lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
|
||||
}
|
||||
// BCC is intentionally omitted from the MIME headers per RFC 5322
|
||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
||||
lines.push(formatHeader('Message-ID', input.messageId ?? `<${crypto.randomUUID()}@smime.local>`));
|
||||
if (input.inReplyTo) {
|
||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||
}
|
||||
if (input.references?.length) {
|
||||
lines.push(formatHeader('References', input.references.join(' ')));
|
||||
}
|
||||
lines.push('MIME-Version: 1.0');
|
||||
|
||||
const hasText = !!input.textBody;
|
||||
const hasHtml = !!input.htmlBody;
|
||||
const hasAttachments = !!input.attachments?.length;
|
||||
|
||||
if (!hasAttachments && hasText && !hasHtml) {
|
||||
// text/plain only
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
} else if (!hasAttachments && hasText && hasHtml) {
|
||||
// multipart/alternative
|
||||
const altBoundary = generateBoundary();
|
||||
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
||||
lines.push('');
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
lines.push(`--${altBoundary}--`);
|
||||
} else if (!hasAttachments && !hasText && hasHtml) {
|
||||
// html only
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
} else if (hasAttachments) {
|
||||
// multipart/mixed
|
||||
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
||||
lines.push('');
|
||||
|
||||
// Body part
|
||||
if (hasText && hasHtml) {
|
||||
const altBoundary = generateBoundary();
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
||||
lines.push('');
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
lines.push(`--${altBoundary}--`);
|
||||
} else if (hasText) {
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
} else if (hasHtml) {
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (const att of input.attachments!) {
|
||||
lines.push(`--${boundary}`);
|
||||
const disposition = att.cid ? 'inline' : 'attachment';
|
||||
lines.push(`Content-Type: ${att.contentType}; name="${encodeHeaderValue(att.filename)}"`);
|
||||
lines.push(`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(att.filename)}"`);
|
||||
lines.push('Content-Transfer-Encoding: base64');
|
||||
if (att.cid) {
|
||||
lines.push(`Content-ID: <${att.cid}>`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(base64Encode(att.content));
|
||||
}
|
||||
lines.push(`--${boundary}--`);
|
||||
} else {
|
||||
// Empty body
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const raw = lines.join(CRLF);
|
||||
return new TextEncoder().encode(raw);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateBoundary(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `----=_Part_${hex}`;
|
||||
}
|
||||
|
||||
function formatAddress(addr: { name?: string; email: string }): string {
|
||||
if (addr.name) {
|
||||
// RFC 5322 quoted-string for display name
|
||||
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return `"${escaped}" <${addr.email}>`;
|
||||
}
|
||||
return addr.email;
|
||||
}
|
||||
|
||||
function formatHeader(name: string, value: string): string {
|
||||
const full = `${name}: ${value}`;
|
||||
// RFC 5322 line length limit: fold at 76 chars
|
||||
if (full.length <= 76) return full;
|
||||
const parts: string[] = [];
|
||||
let remaining = full;
|
||||
let first = true;
|
||||
while (remaining.length > 76) {
|
||||
let breakAt = 76;
|
||||
// Find a space to break at
|
||||
const spaceIdx = remaining.lastIndexOf(' ', 76);
|
||||
if (spaceIdx > (first ? name.length + 2 : 1)) {
|
||||
breakAt = spaceIdx;
|
||||
}
|
||||
parts.push(remaining.slice(0, breakAt));
|
||||
remaining = ' ' + remaining.slice(breakAt).trimStart();
|
||||
first = false;
|
||||
}
|
||||
parts.push(remaining);
|
||||
return parts.join(CRLF);
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
// Use RFC 2047 encoded-word if non-ASCII
|
||||
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
||||
const encoded = Array.from(new TextEncoder().encode(value))
|
||||
.map((b) => {
|
||||
if (
|
||||
(b >= 0x30 && b <= 0x39) || // 0-9
|
||||
(b >= 0x41 && b <= 0x5a) || // A-Z
|
||||
(b >= 0x61 && b <= 0x7a) // a-z
|
||||
) {
|
||||
return String.fromCharCode(b);
|
||||
}
|
||||
return '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
||||
})
|
||||
.join('');
|
||||
return `=?UTF-8?Q?${encoded}?=`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
// RFC 5322 date format
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const d = days[date.getUTCDay()];
|
||||
const dd = date.getUTCDate();
|
||||
const m = months[date.getUTCMonth()];
|
||||
const y = date.getUTCFullYear();
|
||||
const hh = date.getUTCHours().toString().padStart(2, '0');
|
||||
const mm = date.getUTCMinutes().toString().padStart(2, '0');
|
||||
const ss = date.getUTCSeconds().toString().padStart(2, '0');
|
||||
return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`;
|
||||
}
|
||||
|
||||
export interface SmimeWrapInput {
|
||||
from: { name?: string; email: string };
|
||||
to: { name?: string; email: string }[];
|
||||
cc?: { name?: string; email: string }[];
|
||||
subject: string;
|
||||
date?: Date;
|
||||
messageId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
smimeType: 'signed-data' | 'enveloped-data';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message.
|
||||
*
|
||||
* The server needs RFC 5322 headers (From, To, Subject, etc.) to route
|
||||
* the message; the CMS blob becomes the base64-encoded body.
|
||||
*/
|
||||
export function wrapCmsAsSmimeMessage(cmsBlob: Blob | ArrayBuffer | Uint8Array, input: SmimeWrapInput): Blob {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(formatHeader('From', formatAddress(input.from)));
|
||||
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
|
||||
if (input.cc?.length) {
|
||||
lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
|
||||
}
|
||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
||||
lines.push(formatHeader('Message-ID', input.messageId ?? `<${crypto.randomUUID()}@smime.local>`));
|
||||
if (input.inReplyTo) {
|
||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||
}
|
||||
if (input.references?.length) {
|
||||
lines.push(formatHeader('References', input.references.join(' ')));
|
||||
}
|
||||
lines.push('MIME-Version: 1.0');
|
||||
lines.push(`Content-Type: application/pkcs7-mime; smime-type=${input.smimeType}; name="smime.p7m"`);
|
||||
lines.push('Content-Transfer-Encoding: base64');
|
||||
lines.push('Content-Disposition: attachment; filename="smime.p7m"');
|
||||
lines.push('');
|
||||
|
||||
const headerPart = lines.join(CRLF);
|
||||
|
||||
// We'll combine header bytes + base64 body
|
||||
const headerBytes = new TextEncoder().encode(headerPart);
|
||||
|
||||
return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' });
|
||||
}
|
||||
|
||||
function cmsToBase64Blob(data: Blob | ArrayBuffer | Uint8Array): Blob {
|
||||
let bytes: Uint8Array;
|
||||
if (data instanceof Uint8Array) {
|
||||
bytes = data;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(data);
|
||||
} else {
|
||||
// Blob — we need sync; caller should have converted. Fallback to empty.
|
||||
bytes = new Uint8Array(0);
|
||||
}
|
||||
const b64 = base64Encode(bytes.buffer as ArrayBuffer);
|
||||
return new Blob([new TextEncoder().encode(b64 + CRLF)]);
|
||||
}
|
||||
|
||||
/** Encode string as quoted-printable (RFC 2045). */
|
||||
export function quotedPrintableEncode(input: string): string {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const lines: string[] = [];
|
||||
let line = '';
|
||||
|
||||
for (const b of bytes) {
|
||||
let encoded: string;
|
||||
if (b === 0x0d || b === 0x0a) {
|
||||
// Pass through CRLF as-is (handled below)
|
||||
encoded = String.fromCharCode(b);
|
||||
} else if (
|
||||
b === 0x09 || // tab
|
||||
(b >= 0x20 && b <= 0x7e && b !== 0x3d) // printable, not '='
|
||||
) {
|
||||
encoded = String.fromCharCode(b);
|
||||
} else {
|
||||
encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
||||
}
|
||||
|
||||
if (b === 0x0a) {
|
||||
// End current line (strip any trailing \r already added)
|
||||
if (line.endsWith('\r')) {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
lines.push(line);
|
||||
line = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length + encoded.length > 75) {
|
||||
lines.push(line + '=');
|
||||
line = encoded;
|
||||
} else {
|
||||
line += encoded;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
return lines.join(CRLF);
|
||||
}
|
||||
|
||||
/** Encode ArrayBuffer as base64 with line breaks at 76 chars. */
|
||||
export function base64Encode(data: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(data);
|
||||
let binary = '';
|
||||
for (const b of bytes) {
|
||||
binary += String.fromCharCode(b);
|
||||
}
|
||||
const b64 = btoa(binary);
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < b64.length; i += 76) {
|
||||
lines.push(b64.slice(i, i + 76));
|
||||
}
|
||||
return lines.join(CRLF);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { decryptPrivateKeyBytes } from './pkcs12-import';
|
||||
import type { SmimeKeyRecord } from './types';
|
||||
|
||||
function stringToArrayBuffer(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an S/MIME key record as a PKCS#12 (.p12) file.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Decrypt the stored PKCS#8 private key bytes using the storage passphrase.
|
||||
* 2. Build a PKCS#12 container with the private key, leaf cert, and chain.
|
||||
* 3. Protect the PKCS#12 with the export passphrase.
|
||||
* 4. Return the resulting bytes for browser download.
|
||||
*/
|
||||
export async function exportPkcs12(
|
||||
record: SmimeKeyRecord,
|
||||
storagePassphrase: string,
|
||||
exportPassphrase: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
// Step 1: Decrypt the stored private key
|
||||
const pkcs8Bytes = await decryptPrivateKeyBytes(record, storagePassphrase);
|
||||
|
||||
// Step 2: Parse the leaf certificate
|
||||
const leafCertAsn1 = asn1js.fromBER(record.certificate);
|
||||
if (leafCertAsn1.offset === -1) {
|
||||
throw new Error('Failed to parse leaf certificate');
|
||||
}
|
||||
const leafCert = new pkijs.Certificate({ schema: leafCertAsn1.result });
|
||||
|
||||
// Parse chain certificates
|
||||
const chainCerts = record.certificateChain.map((chainDer) => {
|
||||
const chainAsn1 = asn1js.fromBER(chainDer);
|
||||
if (chainAsn1.offset === -1) {
|
||||
throw new Error('Failed to parse chain certificate');
|
||||
}
|
||||
return new pkijs.Certificate({ schema: chainAsn1.result });
|
||||
});
|
||||
|
||||
const passwordBuf = stringToArrayBuffer(exportPassphrase);
|
||||
|
||||
// Step 3: Build the PKCS#12 structure
|
||||
// Create key bag
|
||||
const keyBag = new pkijs.PKCS8ShroudedKeyBag({
|
||||
parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes),
|
||||
});
|
||||
|
||||
await keyBag.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
contentEncryptionAlgorithm: {
|
||||
name: 'AES-CBC',
|
||||
length: 256,
|
||||
} as unknown as Parameters<typeof keyBag.makeInternalValues>[0]['contentEncryptionAlgorithm'],
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
iterationCount: 100_000,
|
||||
});
|
||||
|
||||
const keyBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.2', // pkcs8ShroudedKeyBag
|
||||
bagValue: keyBag,
|
||||
bagAttributes: [
|
||||
new pkijs.Attribute({
|
||||
type: '1.2.840.113549.1.9.20', // friendlyName
|
||||
values: [new asn1js.BmpString({ value: record.email })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Create cert bags
|
||||
const certBags = [
|
||||
new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3', // certBag
|
||||
bagValue: new pkijs.CertBag({
|
||||
parsedValue: leafCert,
|
||||
}),
|
||||
bagAttributes: [
|
||||
new pkijs.Attribute({
|
||||
type: '1.2.840.113549.1.9.20',
|
||||
values: [new asn1js.BmpString({ value: record.email })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...chainCerts.map(
|
||||
(cert) =>
|
||||
new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3',
|
||||
bagValue: new pkijs.CertBag({
|
||||
parsedValue: cert,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// Build authenticated safe with two SafeContents:
|
||||
// 1. Key bag (password-encrypted)
|
||||
// 2. Cert bags (unencrypted)
|
||||
const authenticatedSafe = new pkijs.AuthenticatedSafe({
|
||||
parsedValue: {
|
||||
safeContents: [
|
||||
{
|
||||
privacyMode: 0, // no extra encryption — key bag is already shrouded
|
||||
value: new pkijs.SafeContents({
|
||||
safeBags: [keyBagSafe],
|
||||
}),
|
||||
},
|
||||
{
|
||||
privacyMode: 0,
|
||||
value: new pkijs.SafeContents({
|
||||
safeBags: certBags,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await authenticatedSafe.makeInternalValues({
|
||||
safeContents: [{}, {}],
|
||||
});
|
||||
|
||||
const pfx = new pkijs.PFX({
|
||||
parsedValue: {
|
||||
integrityMode: 0,
|
||||
authenticatedSafe,
|
||||
},
|
||||
});
|
||||
|
||||
await pfx.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
iterations: 100_000,
|
||||
pbkdf2HashAlgorithm: 'SHA-256',
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
});
|
||||
|
||||
// Step 4: Serialize to DER
|
||||
return pfx.toSchema().toBER(false);
|
||||
}
|
||||
|
||||
/** Trigger a browser download of the PKCS#12 file. */
|
||||
export function downloadPkcs12(p12Bytes: ArrayBuffer, filename: string): void {
|
||||
const blob = new Blob([p12Bytes], { type: 'application/x-pkcs12' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import {
|
||||
parseCertificateDer,
|
||||
extractCertificateInfo,
|
||||
classifyCapabilities,
|
||||
} from './certificate-utils';
|
||||
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
||||
|
||||
const KDF_ITERATIONS = 600_000;
|
||||
const AES_KEY_LENGTH = 256;
|
||||
|
||||
function stringToAB(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Parse a PKCS#12 (.p12/.pfx) file and produce an encrypted-at-rest key record. */
|
||||
export async function importPkcs12(
|
||||
p12Bytes: ArrayBuffer,
|
||||
p12Passphrase: string,
|
||||
storagePassphrase: string,
|
||||
): Promise<Pkcs12ImportResult> {
|
||||
// Parse PKCS#12 container
|
||||
const asn1 = asn1js.fromBER(p12Bytes);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid PKCS#12 file: ASN.1 parsing failed');
|
||||
}
|
||||
|
||||
const pfx = new pkijs.PFX({ schema: asn1.result });
|
||||
|
||||
// Verify MAC if present
|
||||
if (pfx.macData) {
|
||||
const macOk = await pfx.parsedValue?.integrityMode === undefined || true;
|
||||
// PKIjs handles MAC verification internally during parseInternalValues
|
||||
}
|
||||
|
||||
// Parse internal values
|
||||
await pfx.parseInternalValues({
|
||||
password: stringToAB(p12Passphrase),
|
||||
});
|
||||
|
||||
// Extract certificates and private key from parsed PKCS#12
|
||||
let leafCertDer: ArrayBuffer | null = null;
|
||||
let leafCert: pkijs.Certificate | null = null;
|
||||
const chainCertsDer: ArrayBuffer[] = [];
|
||||
let privateKeyInfo: pkijs.PrivateKeyInfo | null = null;
|
||||
|
||||
if (!pfx.parsedValue?.authenticatedSafe) {
|
||||
throw new Error('PKCS#12 file does not contain an authenticated safe');
|
||||
}
|
||||
|
||||
// Parse the authenticated safe contents (inner SafeContents)
|
||||
const authSafe = pfx.parsedValue.authenticatedSafe;
|
||||
const safeContentsParams = authSafe.safeContents.map((ci: pkijs.ContentInfo) => {
|
||||
// encryptedData (1.2.840.113549.1.7.6) needs the password
|
||||
if (ci.contentType === '1.2.840.113549.1.7.6') {
|
||||
return { password: stringToAB(p12Passphrase) };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
|
||||
|
||||
for (const safeContent of authSafe.parsedValue.safeContents) {
|
||||
const sc = safeContent.value ?? safeContent.parsedValue;
|
||||
if (!sc) continue;
|
||||
|
||||
for (const safeBag of sc.safeBags) {
|
||||
// PKCS#12 bag types
|
||||
switch (safeBag.bagId) {
|
||||
case '1.2.840.113549.1.12.10.1.3': {
|
||||
// CertBag
|
||||
const certBag = safeBag.bagValue as pkijs.CertBag;
|
||||
|
||||
// parsedValue may already be a Certificate (built in-memory)
|
||||
let cert: pkijs.Certificate | null = null;
|
||||
let der: ArrayBuffer | null = null;
|
||||
|
||||
if (certBag.parsedValue instanceof pkijs.Certificate) {
|
||||
cert = certBag.parsedValue;
|
||||
der = cert.toSchema(true).toBER(false);
|
||||
} else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) {
|
||||
// x509Certificate — extract DER from the OCTET STRING
|
||||
const certDerBytes = (certBag.certValue as asn1js.OctetString).valueBlock.valueHexView;
|
||||
const certAsn1 = asn1js.fromBER(certDerBytes);
|
||||
if (certAsn1.offset !== -1) {
|
||||
cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
der = new Uint8Array(certDerBytes).buffer as ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
if (cert && der) {
|
||||
if (!leafCertDer) {
|
||||
leafCertDer = der;
|
||||
leafCert = cert;
|
||||
} else {
|
||||
chainCertsDer.push(der);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '1.2.840.113549.1.12.10.1.1': {
|
||||
// KeyBag (unencrypted private key)
|
||||
privateKeyInfo = safeBag.bagValue as pkijs.PrivateKeyInfo;
|
||||
break;
|
||||
}
|
||||
case '1.2.840.113549.1.12.10.1.2': {
|
||||
// PKCS8ShroudedKeyBag (encrypted private key)
|
||||
const shroudedBag = safeBag.bagValue as pkijs.PKCS8ShroudedKeyBag;
|
||||
if (shroudedBag.parsedValue) {
|
||||
privateKeyInfo = shroudedBag.parsedValue;
|
||||
} else {
|
||||
// Decrypt shrouded key bag to get private key info
|
||||
await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise<void> }).parseInternalValues({
|
||||
password: stringToAB(p12Passphrase),
|
||||
});
|
||||
if (shroudedBag.parsedValue) {
|
||||
privateKeyInfo = shroudedBag.parsedValue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!leafCert || !leafCertDer) {
|
||||
throw new Error('No certificate found in PKCS#12 file');
|
||||
}
|
||||
if (!privateKeyInfo) {
|
||||
throw new Error('No private key found in PKCS#12 file');
|
||||
}
|
||||
|
||||
// Extract PKCS#8 private key bytes
|
||||
const pkcs8Bytes = privateKeyInfo.toSchema().toBER(false);
|
||||
|
||||
// Encrypt the private key for at-rest storage
|
||||
const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase);
|
||||
|
||||
// Extract certificate metadata
|
||||
const certInfo = await extractCertificateInfo(leafCert, leafCertDer);
|
||||
const capabilities = classifyCapabilities(leafCert);
|
||||
|
||||
const email = certInfo.emailAddresses[0] ?? '';
|
||||
|
||||
const keyRecord: SmimeKeyRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
email: email.toLowerCase(),
|
||||
certificate: leafCertDer,
|
||||
certificateChain: chainCertsDer,
|
||||
encryptedPrivateKey: encrypted,
|
||||
salt,
|
||||
iv,
|
||||
kdfIterations: KDF_ITERATIONS,
|
||||
issuer: certInfo.issuer,
|
||||
subject: certInfo.subject,
|
||||
serialNumber: certInfo.serialNumber,
|
||||
notBefore: certInfo.notBefore,
|
||||
notAfter: certInfo.notAfter,
|
||||
fingerprint: certInfo.fingerprint,
|
||||
algorithm: certInfo.algorithm,
|
||||
capabilities,
|
||||
};
|
||||
|
||||
return { keyRecord, certInfo };
|
||||
}
|
||||
|
||||
// ── Private key encryption / decryption ──────────────────────────────
|
||||
|
||||
async function deriveWrappingKey(
|
||||
passphrase: string,
|
||||
salt: ArrayBuffer,
|
||||
iterations: number,
|
||||
): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: AES_KEY_LENGTH },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptPrivateKey(
|
||||
pkcs8Bytes: ArrayBuffer,
|
||||
passphrase: string,
|
||||
): Promise<{ encrypted: ArrayBuffer; salt: ArrayBuffer; iv: ArrayBuffer }> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32)).buffer;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12)).buffer;
|
||||
const wrappingKey = await deriveWrappingKey(passphrase, salt, KDF_ITERATIONS);
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
wrappingKey,
|
||||
pkcs8Bytes,
|
||||
);
|
||||
return { encrypted, salt, iv };
|
||||
}
|
||||
|
||||
export interface UnlockedKeyPair {
|
||||
signingKey: CryptoKey;
|
||||
decryptionKey?: CryptoKey;
|
||||
}
|
||||
|
||||
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
||||
export async function unlockPrivateKey(
|
||||
record: SmimeKeyRecord,
|
||||
passphrase: string,
|
||||
): Promise<UnlockedKeyPair> {
|
||||
const wrappingKey = await deriveWrappingKey(
|
||||
passphrase,
|
||||
record.salt,
|
||||
record.kdfIterations,
|
||||
);
|
||||
|
||||
let pkcs8Bytes: ArrayBuffer;
|
||||
try {
|
||||
pkcs8Bytes = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: record.iv },
|
||||
wrappingKey,
|
||||
record.encryptedPrivateKey,
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Incorrect passphrase');
|
||||
}
|
||||
|
||||
const isEcdsa = record.algorithm.startsWith('ECDSA');
|
||||
const signAlg = isEcdsa
|
||||
? { name: 'ECDSA', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
|
||||
: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
||||
const decryptAlg = isEcdsa
|
||||
? { name: 'ECDH', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
|
||||
: { name: 'RSA-OAEP', hash: 'SHA-256' };
|
||||
const decryptUsages: globalThis.KeyUsage[] = isEcdsa ? ['deriveBits'] : ['decrypt'];
|
||||
|
||||
// Import for signing
|
||||
let signingKey: CryptoKey;
|
||||
try {
|
||||
signingKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, signAlg, false, ['sign']);
|
||||
} catch {
|
||||
// Key may only support decryption (key-encipherment-only cert)
|
||||
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||
return { signingKey: decryptionKey, decryptionKey };
|
||||
}
|
||||
|
||||
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
|
||||
let decryptionKey: CryptoKey | undefined;
|
||||
try {
|
||||
decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||
} catch {
|
||||
// Key may only support signing (digitalSignature-only cert)
|
||||
}
|
||||
|
||||
return { signingKey, decryptionKey };
|
||||
}
|
||||
|
||||
/** Get decrypted PKCS#8 bytes (for export flow). */
|
||||
export async function decryptPrivateKeyBytes(
|
||||
record: SmimeKeyRecord,
|
||||
passphrase: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const wrappingKey = await deriveWrappingKey(
|
||||
passphrase,
|
||||
record.salt,
|
||||
record.kdfIterations,
|
||||
);
|
||||
|
||||
try {
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: record.iv },
|
||||
wrappingKey,
|
||||
record.encryptedPrivateKey,
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Incorrect passphrase');
|
||||
}
|
||||
}
|
||||
|
||||
function ecdsaCurveFromAlg(alg: string): string {
|
||||
if (alg.includes('P256') || alg.includes('P-256')) return 'P-256';
|
||||
if (alg.includes('P384') || alg.includes('P-384')) return 'P-384';
|
||||
if (alg.includes('P521') || alg.includes('P-521')) return 'P-521';
|
||||
return 'P-256';
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Decrypt CMS EnvelopedData to recover the inner MIME content.
|
||||
*
|
||||
* Supports both issuerAndSerialNumber and subjectKeyIdentifier
|
||||
* recipient identifier types per RFC 8551.
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import type { SmimeKeyRecord } from './types';
|
||||
|
||||
export interface DecryptionInput {
|
||||
/** Raw CMS EnvelopedData bytes (DER) */
|
||||
cmsBytes: ArrayBuffer;
|
||||
/** All imported key records to try matching against */
|
||||
keyRecords: SmimeKeyRecord[];
|
||||
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */
|
||||
unlockedKeys: Map<string, CryptoKey>;
|
||||
}
|
||||
|
||||
export interface DecryptionResult {
|
||||
/** The decrypted inner MIME bytes */
|
||||
mimeBytes: Uint8Array;
|
||||
/** The key record that was used to decrypt */
|
||||
keyRecordId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to decrypt CMS EnvelopedData.
|
||||
*
|
||||
* Tries each matching key record against the recipient infos in the CMS structure.
|
||||
*
|
||||
* @throws Error if no matching key is found, key is locked, or decryption fails
|
||||
*/
|
||||
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
||||
const { cmsBytes, keyRecords, unlockedKeys } = input;
|
||||
|
||||
// Parse the CMS ContentInfo wrapper
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const envelopedData = extractEnvelopedData(contentInfo);
|
||||
|
||||
// Find matching key records
|
||||
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||
|
||||
if (matchedRecords.length === 0) {
|
||||
throw new Error('No imported S/MIME key matches any recipient in this encrypted message');
|
||||
}
|
||||
|
||||
// Try each matched record
|
||||
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
||||
const privateKey = unlockedKeys.get(keyRecord.id);
|
||||
if (!privateKey) {
|
||||
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord);
|
||||
return {
|
||||
mimeBytes: new Uint8Array(decrypted),
|
||||
keyRecordId: keyRecord.id,
|
||||
};
|
||||
} catch {
|
||||
// This key didn't work, try the next one
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we had matching records but none were unlocked
|
||||
const hasLockedMatch = matchedRecords.some(m => !unlockedKeys.has(m.keyRecord.id));
|
||||
if (hasLockedMatch) {
|
||||
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!;
|
||||
throw new SmimeKeyLockedError(
|
||||
'S/MIME key is locked. Unlock it to decrypt this message.',
|
||||
lockedRecord.keyRecord.id,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Failed to decrypt message with any available key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key record IDs that could potentially decrypt a message.
|
||||
* Useful for prompting the user to unlock the right key.
|
||||
*/
|
||||
export function findDecryptionCandidates(
|
||||
cmsBytes: ArrayBuffer,
|
||||
keyRecords: SmimeKeyRecord[],
|
||||
): string[] {
|
||||
try {
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const envelopedData = extractEnvelopedData(contentInfo);
|
||||
const matches = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||
return matches.map(m => m.keyRecord.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class SmimeKeyLockedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly keyRecordId: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SmimeKeyLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/**
|
||||
* Normalize raw blob bytes into DER-encoded CMS data.
|
||||
*
|
||||
* JMAP servers may return the CMS blob in various formats:
|
||||
* - Raw DER binary (starts with 0x30 ASN.1 SEQUENCE tag)
|
||||
* - Base64-encoded DER
|
||||
* - Full MIME part with headers followed by base64 body
|
||||
* - PEM-wrapped (-----BEGIN PKCS7-----)
|
||||
*
|
||||
* This function detects the format and returns raw DER bytes.
|
||||
*/
|
||||
export function normalizeCmsBytes(raw: ArrayBuffer): ArrayBuffer {
|
||||
if (raw.byteLength === 0) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(raw);
|
||||
|
||||
// Already valid DER — starts with ASN.1 SEQUENCE tag
|
||||
if (bytes[0] === 0x30) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
let text = new TextDecoder().decode(raw);
|
||||
|
||||
const looksMostlyText = (() => {
|
||||
const sample = text.slice(0, Math.min(text.length, 2048));
|
||||
if (sample.length === 0) return false;
|
||||
let printable = 0;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const code = sample.charCodeAt(i);
|
||||
if (
|
||||
code === 0x09 ||
|
||||
code === 0x0a ||
|
||||
code === 0x0d ||
|
||||
(code >= 0x20 && code <= 0x7e)
|
||||
) {
|
||||
printable++;
|
||||
}
|
||||
}
|
||||
return printable / sample.length > 0.85;
|
||||
})();
|
||||
|
||||
// Check if the blob contains MIME headers (e.g., server returned full part
|
||||
// including Content-Transfer-Encoding header)
|
||||
const headerEndMatch = text.match(/\r?\n\r?\n/);
|
||||
const hasMimeHeaderHints = /content-type:|content-transfer-encoding:|mime-version:/i.test(text.slice(0, Math.min(text.length, 8192)));
|
||||
if (looksMostlyText && headerEndMatch && headerEndMatch.index !== undefined && hasMimeHeaderHints) {
|
||||
// Strip everything before the blank line separating headers from body
|
||||
text = text.substring(headerEndMatch.index + headerEndMatch[0].length);
|
||||
}
|
||||
|
||||
// Strip PEM armour if present
|
||||
text = text
|
||||
.replace(/-----BEGIN [A-Z0-9 ]+-----/g, '')
|
||||
.replace(/-----END [A-Z0-9 ]+-----/g, '');
|
||||
|
||||
// Remove all whitespace and try base64 decode
|
||||
text = text.replace(/\s/g, '');
|
||||
|
||||
if (text.length === 0) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = atob(text);
|
||||
const decoded = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
|
||||
if (decoded.length > 0 && decoded[0] === 0x30) {
|
||||
return decoded.buffer as ArrayBuffer;
|
||||
}
|
||||
} catch { /* non-DER data, continue to fallback */ }
|
||||
|
||||
// Fallback: parse explicit MIME base64 sections
|
||||
if (looksMostlyText) {
|
||||
const originalText = new TextDecoder().decode(raw);
|
||||
const sectionRegex = /content-transfer-encoding:\s*base64[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--[^\r\n]+|$)/ig;
|
||||
const sectionBlocks: string[] = [];
|
||||
let sectionMatch: RegExpExecArray | null = null;
|
||||
while ((sectionMatch = sectionRegex.exec(originalText)) !== null) {
|
||||
sectionBlocks.push(sectionMatch[1]);
|
||||
}
|
||||
|
||||
for (const block of sectionBlocks) {
|
||||
const cleaned = block.replace(/\s/g, '');
|
||||
if (cleaned.length < 8 || !/^[A-Za-z0-9+/=]+$/.test(cleaned)) continue;
|
||||
try {
|
||||
const binary = atob(cleaned);
|
||||
const decoded = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
|
||||
if (decoded.length > 0 && decoded[0] === 0x30) {
|
||||
return decoded.buffer as ArrayBuffer;
|
||||
}
|
||||
} catch {
|
||||
// try next section
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: find base64-like blocks and keep only DER-looking decodes
|
||||
const base64Blocks = originalText.match(/[A-Za-z0-9+/=\r\n]{128,}/g) || [];
|
||||
const cleaned = base64Blocks
|
||||
.map(block => block.replace(/\s/g, ''))
|
||||
.filter(block => block.length >= 128 && /^[A-Za-z0-9+/=]+$/.test(block));
|
||||
|
||||
cleaned.sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const block of cleaned) {
|
||||
try {
|
||||
const binary = atob(block);
|
||||
const decoded = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
|
||||
if (decoded.length > 0 && decoded[0] === 0x30) {
|
||||
return decoded.buffer as ArrayBuffer;
|
||||
}
|
||||
} catch {
|
||||
// try next block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not decodable — return original bytes
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
||||
const asn1 = asn1js.fromBER(der);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid ASN.1 data — cannot parse CMS envelope');
|
||||
}
|
||||
try {
|
||||
return new pkijs.ContentInfo({ schema: asn1.result });
|
||||
} catch {
|
||||
throw new Error('Invalid ASN.1 data — cannot parse CMS envelope');
|
||||
}
|
||||
}
|
||||
|
||||
function extractEnvelopedData(contentInfo: pkijs.ContentInfo): pkijs.EnvelopedData {
|
||||
// OID 1.2.840.113549.1.7.3 = enveloped-data
|
||||
if (contentInfo.contentType !== '1.2.840.113549.1.7.3') {
|
||||
throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`);
|
||||
}
|
||||
return new pkijs.EnvelopedData({ schema: contentInfo.content });
|
||||
}
|
||||
|
||||
interface RecipientMatch {
|
||||
keyRecord: SmimeKeyRecord;
|
||||
recipientIndex: number;
|
||||
}
|
||||
|
||||
function findMatchingKeyRecords(
|
||||
envelopedData: pkijs.EnvelopedData,
|
||||
keyRecords: SmimeKeyRecord[],
|
||||
): RecipientMatch[] {
|
||||
const matches: RecipientMatch[] = [];
|
||||
|
||||
for (let i = 0; i < envelopedData.recipientInfos.length; i++) {
|
||||
const ri = envelopedData.recipientInfos[i];
|
||||
|
||||
// RecipientInfo is a wrapper: variant=1 → KeyTransRecipientInfo
|
||||
const ktri = ri instanceof pkijs.KeyTransRecipientInfo
|
||||
? ri
|
||||
: (ri as { variant?: number; value?: unknown }).variant === 1 && (ri as { value?: unknown }).value instanceof pkijs.KeyTransRecipientInfo
|
||||
? (ri as { value: pkijs.KeyTransRecipientInfo }).value
|
||||
: null;
|
||||
|
||||
if (ktri) {
|
||||
for (const keyRecord of keyRecords) {
|
||||
if (matchesKeyTransRecipient(ktri, keyRecord)) {
|
||||
matches.push({ keyRecord, recipientIndex: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function matchesKeyTransRecipient(
|
||||
recipientInfo: pkijs.KeyTransRecipientInfo,
|
||||
keyRecord: SmimeKeyRecord,
|
||||
): boolean {
|
||||
const rid = recipientInfo.rid;
|
||||
|
||||
// IssuerAndSerialNumber matching
|
||||
if (rid instanceof pkijs.IssuerAndSerialNumber) {
|
||||
try {
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
if (certAsn1.offset === -1) return false;
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
// Compare serial numbers
|
||||
const ridSerial = Buffer.from(rid.serialNumber.valueBlock.valueHexView).toString('hex');
|
||||
const certSerial = Buffer.from(cert.serialNumber.valueBlock.valueHexView).toString('hex');
|
||||
if (ridSerial !== certSerial) return false;
|
||||
|
||||
// Compare issuers (compare DER encoding)
|
||||
const ridIssuerDer = rid.issuer.toSchema().toBER(false);
|
||||
const certIssuerDer = cert.issuer.toSchema().toBER(false);
|
||||
return arraysEqual(new Uint8Array(ridIssuerDer), new Uint8Array(certIssuerDer));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SubjectKeyIdentifier matching
|
||||
if (rid instanceof asn1js.OctetString) {
|
||||
try {
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
if (certAsn1.offset === -1) return false;
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
// Find the SubjectKeyIdentifier extension
|
||||
const skiExt = cert.extensions?.find(
|
||||
ext => ext.extnID === '2.5.29.14', // id-ce-subjectKeyIdentifier
|
||||
);
|
||||
if (!skiExt) return false;
|
||||
|
||||
const skiValue = asn1js.fromBER(skiExt.extnValue.valueBlock.valueHexView);
|
||||
if (skiValue.offset === -1) return false;
|
||||
const ski = (skiValue.result as asn1js.OctetString).valueBlock.valueHexView;
|
||||
|
||||
return arraysEqual(
|
||||
new Uint8Array(ski),
|
||||
new Uint8Array(rid.valueBlock.valueHexView),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function decryptWithKey(
|
||||
envelopedData: pkijs.EnvelopedData,
|
||||
recipientIndex: number,
|
||||
privateKey: CryptoKey,
|
||||
keyRecord: SmimeKeyRecord,
|
||||
): Promise<ArrayBuffer> {
|
||||
// Parse the certificate for pkijs
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
const result = await envelopedData.decrypt(
|
||||
recipientIndex,
|
||||
{
|
||||
recipientCertificate: cert,
|
||||
recipientPrivateKey: privateKey,
|
||||
},
|
||||
cryptoEngine,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Detect S/MIME content in an email message.
|
||||
*
|
||||
* Checks Content-Type headers, bodyStructure, and attachment metadata
|
||||
* to determine if a message contains CMS signed or encrypted content.
|
||||
*/
|
||||
|
||||
export type SmimeContentType =
|
||||
| 'enveloped-data' // encrypted
|
||||
| 'signed-data' // opaque signed
|
||||
| 'detached-sig' // multipart/signed (deferred in v1)
|
||||
| null;
|
||||
|
||||
export interface SmimeDetectionResult {
|
||||
/** Primary S/MIME content type detected, or null if none */
|
||||
type: SmimeContentType;
|
||||
/** The blobId to fetch for CMS processing (enveloped-data or signed-data) */
|
||||
blobId?: string;
|
||||
/** The partId containing the CMS data */
|
||||
partId?: string;
|
||||
/** Whether this is a v1-supported type */
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
interface EmailBodyPart {
|
||||
partId?: string;
|
||||
blobId?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
disposition?: string;
|
||||
subParts?: EmailBodyPart[];
|
||||
headers?: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect S/MIME content from email metadata.
|
||||
*
|
||||
* @param contentType - The top-level Content-Type header value
|
||||
* @param bodyStructure - The JMAP bodyStructure tree
|
||||
* @param attachments - Flat list of attachment parts (from `attachments` property)
|
||||
*/
|
||||
export function detectSmime(
|
||||
contentType?: string,
|
||||
bodyStructure?: EmailBodyPart | null,
|
||||
attachments?: EmailBodyPart[],
|
||||
): SmimeDetectionResult {
|
||||
const noResult: SmimeDetectionResult = { type: null, supported: false };
|
||||
|
||||
// 1. Check top-level Content-Type header
|
||||
if (contentType) {
|
||||
const ct = contentType.toLowerCase();
|
||||
|
||||
if (ct.includes('application/pkcs7-mime') || ct.includes('application/x-pkcs7-mime')) {
|
||||
if (ct.includes('smime-type=enveloped-data')) {
|
||||
const part = findCmsPart(bodyStructure, 'enveloped-data');
|
||||
return {
|
||||
type: 'enveloped-data',
|
||||
blobId: part?.blobId,
|
||||
partId: part?.partId,
|
||||
supported: true,
|
||||
};
|
||||
}
|
||||
if (ct.includes('smime-type=signed-data')) {
|
||||
const part = findCmsPart(bodyStructure, 'signed-data');
|
||||
return {
|
||||
type: 'signed-data',
|
||||
blobId: part?.blobId,
|
||||
partId: part?.partId,
|
||||
supported: true,
|
||||
};
|
||||
}
|
||||
// Generic pkcs7-mime without explicit smime-type — try bodyStructure
|
||||
const part = findCmsPart(bodyStructure, null);
|
||||
if (part) {
|
||||
const partType = inferSmimeType(part);
|
||||
return {
|
||||
type: partType,
|
||||
blobId: part.blobId,
|
||||
partId: part.partId,
|
||||
supported: partType === 'enveloped-data' || partType === 'signed-data',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) {
|
||||
return { type: 'detached-sig', supported: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk bodyStructure tree
|
||||
if (bodyStructure) {
|
||||
const result = walkBodyStructure(bodyStructure);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 3. Check attachment list for .p7m files
|
||||
if (attachments) {
|
||||
for (const att of attachments) {
|
||||
const type = att.type?.toLowerCase() || '';
|
||||
const name = att.name?.toLowerCase() || '';
|
||||
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
const smimeType = inferSmimeTypeFromContentType(type);
|
||||
return {
|
||||
type: smimeType,
|
||||
blobId: att.blobId,
|
||||
partId: att.partId,
|
||||
supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
|
||||
};
|
||||
}
|
||||
|
||||
if (name.endsWith('.p7m')) {
|
||||
return {
|
||||
type: 'enveloped-data', // .p7m is ambiguous but commonly encrypted
|
||||
blobId: att.blobId,
|
||||
partId: att.partId,
|
||||
supported: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (name.endsWith('.p7s')) {
|
||||
return { type: 'detached-sig', blobId: att.blobId, partId: att.partId, supported: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return noResult;
|
||||
}
|
||||
|
||||
function walkBodyStructure(part: EmailBodyPart): SmimeDetectionResult | null {
|
||||
const type = part.type?.toLowerCase() || '';
|
||||
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
const smimeType = inferSmimeTypeFromContentType(type);
|
||||
return {
|
||||
type: smimeType,
|
||||
blobId: part.blobId,
|
||||
partId: part.partId,
|
||||
supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'multipart/signed') {
|
||||
// Check for pkcs7-signature protocol in subparts
|
||||
if (part.subParts?.some(sp => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) {
|
||||
return { type: 'detached-sig', supported: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (part.subParts) {
|
||||
for (const sub of part.subParts) {
|
||||
const result = walkBodyStructure(sub);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCmsPart(bodyStructure: EmailBodyPart | null | undefined, smimeType: string | null): EmailBodyPart | null {
|
||||
if (!bodyStructure) return null;
|
||||
|
||||
const type = bodyStructure.type?.toLowerCase() || '';
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
// JMAP bodyStructure.type may not include smime-type parameter,
|
||||
// so accept any pkcs7-mime part when the smime-type was already
|
||||
// determined from the Content-Type header.
|
||||
return bodyStructure;
|
||||
}
|
||||
|
||||
if (bodyStructure.subParts) {
|
||||
for (const sub of bodyStructure.subParts) {
|
||||
const found = findCmsPart(sub, smimeType);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferSmimeType(part: EmailBodyPart): SmimeContentType {
|
||||
return inferSmimeTypeFromContentType(part.type || '');
|
||||
}
|
||||
|
||||
function inferSmimeTypeFromContentType(ct: string): SmimeContentType {
|
||||
const lower = ct.toLowerCase();
|
||||
if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data';
|
||||
if (lower.includes('smime-type=signed-data')) return 'signed-data';
|
||||
// Default for generic pkcs7-mime: assume enveloped-data (most common)
|
||||
if (lower.includes('application/pkcs7-mime') || lower.includes('application/x-pkcs7-mime')) {
|
||||
return 'enveloped-data';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { parseCertificateDer } from './certificate-utils';
|
||||
|
||||
/**
|
||||
* Produce CMS EnvelopedData for the given MIME content.
|
||||
*
|
||||
* Content type: application/pkcs7-mime; smime-type=enveloped-data
|
||||
*
|
||||
* Always includes the sender's cert so the sender can decrypt their Sent mail.
|
||||
*/
|
||||
export async function smimeEncrypt(
|
||||
mimeBytes: Uint8Array,
|
||||
recipientCertsDer: ArrayBuffer[],
|
||||
senderCertDer: ArrayBuffer,
|
||||
useAes128?: boolean,
|
||||
): Promise<Blob> {
|
||||
// Combine recipient + sender certs, deduplicate by DER bytes
|
||||
const allCertDers = deduplicateCerts([...recipientCertsDer, senderCertDer]);
|
||||
|
||||
if (allCertDers.length === 0) {
|
||||
throw new Error('No recipient certificates provided');
|
||||
}
|
||||
|
||||
// Parse all certificates
|
||||
const recipientCerts = allCertDers.map((der) => parseCertificateDer(der));
|
||||
|
||||
// Build EnvelopedData
|
||||
const cmsEnveloped = new pkijs.EnvelopedData();
|
||||
|
||||
// Add recipient info for each certificate
|
||||
for (const cert of recipientCerts) {
|
||||
cmsEnveloped.addRecipientByCertificate(cert, {
|
||||
oaepHashAlgorithm: 'SHA-256',
|
||||
}, undefined, new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
}));
|
||||
}
|
||||
|
||||
// Encrypt the content
|
||||
const contentEncryptionAlgorithm = useAes128
|
||||
? { name: 'AES-GCM', length: 128 }
|
||||
: { name: 'AES-GCM', length: 256 };
|
||||
|
||||
await cmsEnveloped.encrypt(contentEncryptionAlgorithm, mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength) as ArrayBuffer, new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
}));
|
||||
|
||||
// Wrap in ContentInfo
|
||||
const cms = new pkijs.ContentInfo({
|
||||
contentType: '1.2.840.113549.1.7.3', // id-envelopedData
|
||||
content: cmsEnveloped.toSchema(),
|
||||
});
|
||||
|
||||
const cmsBytes = cms.toSchema().toBER(false);
|
||||
return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=enveloped-data' });
|
||||
}
|
||||
|
||||
/** Remove duplicate DER-encoded certificates based on byte equality. */
|
||||
function deduplicateCerts(certs: ArrayBuffer[]): ArrayBuffer[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ArrayBuffer[] = [];
|
||||
for (const cert of certs) {
|
||||
const key = arrayBufferToHex(cert);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cert);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function arrayBufferToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { Convert } from 'pvtsutils';
|
||||
import { parseCertificateDer } from './certificate-utils';
|
||||
|
||||
/**
|
||||
* Produce an opaque CMS SignedData wrapping the given MIME content.
|
||||
*
|
||||
* Content type: application/pkcs7-mime; smime-type=signed-data
|
||||
* This is the "opaque" form — the content is embedded inside the CMS structure.
|
||||
*/
|
||||
export async function smimeSign(
|
||||
mimeBytes: Uint8Array,
|
||||
privateKey: CryptoKey,
|
||||
signerCertDer: ArrayBuffer,
|
||||
chainCertsDer: ArrayBuffer[] = [],
|
||||
): Promise<Blob> {
|
||||
// Parse signer certificate
|
||||
const signerCert = parseCertificateDer(signerCertDer);
|
||||
|
||||
// Parse chain certificates
|
||||
const chainCerts = chainCertsDer.map((der) => parseCertificateDer(der));
|
||||
|
||||
// Build CMS SignedData
|
||||
const cmsSigned = new pkijs.SignedData({
|
||||
version: 1,
|
||||
encapContentInfo: new pkijs.EncapsulatedContentInfo({
|
||||
eContentType: '1.2.840.113549.1.7.1', // id-data
|
||||
eContent: new asn1js.OctetString({ valueHex: new Uint8Array(mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength)) }),
|
||||
}),
|
||||
signerInfos: [
|
||||
new pkijs.SignerInfo({
|
||||
version: 1,
|
||||
sid: new pkijs.IssuerAndSerialNumber({
|
||||
issuer: signerCert.issuer,
|
||||
serialNumber: signerCert.serialNumber,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
certificates: [signerCert, ...chainCerts],
|
||||
});
|
||||
|
||||
// Determine signing algorithm from the key
|
||||
const algorithm = privateKey.algorithm;
|
||||
const hashAlgorithm = 'SHA-256';
|
||||
|
||||
let signAlg: string;
|
||||
if (algorithm.name === 'RSASSA-PKCS1-v1_5' || algorithm.name === 'RSA-PSS') {
|
||||
signAlg = algorithm.name;
|
||||
} else if (algorithm.name === 'ECDSA') {
|
||||
signAlg = 'ECDSA';
|
||||
} else {
|
||||
signAlg = 'RSASSA-PKCS1-v1_5';
|
||||
}
|
||||
|
||||
// Sign
|
||||
await cmsSigned.sign(privateKey, 0, hashAlgorithm, undefined, new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
}));
|
||||
|
||||
// Wrap in ContentInfo
|
||||
const cms = new pkijs.ContentInfo({
|
||||
contentType: '1.2.840.113549.1.7.2', // id-signedData
|
||||
content: cmsSigned.toSchema(true),
|
||||
});
|
||||
|
||||
const cmsBytes = cms.toSchema().toBER(false);
|
||||
return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=signed-data' });
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||
*
|
||||
* v1 performs cryptographic signature validation and cert validity checks
|
||||
* but does NOT implement full trust-chain or revocation validation.
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { extractCertificateInfo } from './certificate-utils';
|
||||
import type { SmimeStatus, SmimePublicCert } from './types';
|
||||
|
||||
export interface VerificationResult {
|
||||
/** The inner MIME bytes extracted from the opaque SignedData */
|
||||
mimeBytes: Uint8Array;
|
||||
/** Full S/MIME status for display */
|
||||
status: SmimeStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a CMS SignedData structure and extract the encapsulated content.
|
||||
*
|
||||
* @param cmsBytes - Raw DER-encoded CMS SignedData
|
||||
* @param fromHeader - The From header email address for signer identity matching
|
||||
*/
|
||||
export async function smimeVerify(
|
||||
cmsBytes: ArrayBuffer,
|
||||
fromHeader?: string,
|
||||
): Promise<VerificationResult> {
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const signedData = extractSignedData(contentInfo);
|
||||
|
||||
// Extract inner content
|
||||
const innerContent = extractInnerContent(signedData);
|
||||
|
||||
// Extract signer certificate
|
||||
const signerCert = extractSignerCertificate(signedData);
|
||||
if (!signerCert) {
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
isSigned: true,
|
||||
isEncrypted: false,
|
||||
signatureValid: false,
|
||||
signatureError: 'Signer certificate not found in CMS structure',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the signature cryptographically
|
||||
let signatureValid = false;
|
||||
let signatureError: string | undefined;
|
||||
|
||||
try {
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
const verifyResult = await signedData.verify(
|
||||
{
|
||||
signer: 0,
|
||||
checkChain: false, // v1: no trust-chain validation
|
||||
},
|
||||
cryptoEngine,
|
||||
);
|
||||
signatureValid = verifyResult;
|
||||
} catch (err) {
|
||||
signatureError = err instanceof Error ? err.message : 'Signature verification failed';
|
||||
}
|
||||
|
||||
// Extract certificate info for display
|
||||
const certDer = signerCert.toSchema(true).toBER(false);
|
||||
const certInfo = await extractCertificateInfo(signerCert, certDer);
|
||||
|
||||
// Check certificate validity period
|
||||
const now = new Date();
|
||||
const notBefore = new Date(certInfo.notBefore);
|
||||
const notAfter = new Date(certInfo.notAfter);
|
||||
const certExpired = now > notAfter;
|
||||
const certNotYetValid = now < notBefore;
|
||||
|
||||
if (certExpired && !signatureError) {
|
||||
signatureError = 'Signer certificate has expired';
|
||||
}
|
||||
if (certNotYetValid && !signatureError) {
|
||||
signatureError = 'Signer certificate is not yet valid';
|
||||
}
|
||||
|
||||
// Build the signer public cert object
|
||||
const signerEmail = certInfo.emailAddresses[0] ?? '';
|
||||
const signerPublicCert: SmimePublicCert = {
|
||||
id: `signer-${certInfo.fingerprint}`,
|
||||
email: signerEmail.toLowerCase(),
|
||||
certificate: certDer,
|
||||
issuer: certInfo.issuer,
|
||||
subject: certInfo.subject,
|
||||
notBefore: certInfo.notBefore,
|
||||
notAfter: certInfo.notAfter,
|
||||
fingerprint: certInfo.fingerprint,
|
||||
source: 'signed-email',
|
||||
};
|
||||
|
||||
// Check signer identity vs From header
|
||||
let signerEmailMatch: boolean | undefined;
|
||||
if (fromHeader && signerEmail) {
|
||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||
}
|
||||
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
isSigned: true,
|
||||
isEncrypted: false,
|
||||
signatureValid: signatureValid && !certExpired && !certNotYetValid,
|
||||
signatureError,
|
||||
signerCert: signerPublicCert,
|
||||
signerEmailMatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
||||
const asn1 = asn1js.fromBER(der);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid ASN.1 data — cannot parse CMS structure');
|
||||
}
|
||||
return new pkijs.ContentInfo({ schema: asn1.result });
|
||||
}
|
||||
|
||||
function extractSignedData(contentInfo: pkijs.ContentInfo): pkijs.SignedData {
|
||||
// OID 1.2.840.113549.1.7.2 = signed-data
|
||||
if (contentInfo.contentType !== '1.2.840.113549.1.7.2') {
|
||||
throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`);
|
||||
}
|
||||
return new pkijs.SignedData({ schema: contentInfo.content });
|
||||
}
|
||||
|
||||
function extractInnerContent(signedData: pkijs.SignedData): Uint8Array {
|
||||
const eContent = signedData.encapContentInfo?.eContent;
|
||||
if (!eContent) {
|
||||
throw new Error('No encapsulated content in SignedData (detached signature not supported)');
|
||||
}
|
||||
|
||||
if (eContent instanceof asn1js.OctetString) {
|
||||
// Constructed OCTET STRING: data lives in child OctetStrings
|
||||
const children = (eContent.valueBlock as unknown as { value?: asn1js.OctetString[] }).value;
|
||||
if (children?.length) {
|
||||
const chunks = children.map(c => new Uint8Array(c.valueBlock.valueHexView));
|
||||
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Primitive OCTET STRING: data is directly in valueHexView
|
||||
return new Uint8Array(eContent.valueBlock.valueHexView);
|
||||
}
|
||||
|
||||
throw new Error('Unable to extract content from SignedData');
|
||||
}
|
||||
|
||||
function extractSignerCertificate(signedData: pkijs.SignedData): pkijs.Certificate | null {
|
||||
if (!signedData.signerInfos?.length || !signedData.certificates?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signerInfo = signedData.signerInfos[0];
|
||||
const sid = signerInfo.sid;
|
||||
|
||||
// IssuerAndSerialNumber matching
|
||||
if (sid instanceof pkijs.IssuerAndSerialNumber) {
|
||||
for (const certItem of signedData.certificates) {
|
||||
if (!(certItem instanceof pkijs.Certificate)) continue;
|
||||
const cert = certItem;
|
||||
|
||||
// Compare serial numbers
|
||||
const sidSerial = toHex(sid.serialNumber.valueBlock.valueHexView);
|
||||
const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView);
|
||||
if (sidSerial !== certSerial) continue;
|
||||
|
||||
// Compare issuers
|
||||
const sidIssuerDer = new Uint8Array(sid.issuer.toSchema().toBER(false));
|
||||
const certIssuerDer = new Uint8Array(cert.issuer.toSchema().toBER(false));
|
||||
if (arraysEqual(sidIssuerDer, certIssuerDer)) {
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If only one certificate is present, use it as fallback
|
||||
if (signedData.certificates.length === 1) {
|
||||
const cert = signedData.certificates[0];
|
||||
if (cert instanceof pkijs.Certificate) return cert;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toHex(buffer: ArrayBuffer | ArrayBufferView): string {
|
||||
const bytes = buffer instanceof ArrayBuffer
|
||||
? new Uint8Array(buffer)
|
||||
: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Stored record for an imported S/MIME private key + certificate. */
|
||||
export interface SmimeKeyRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
||||
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
||||
encryptedPrivateKey: ArrayBuffer; // AES-GCM wrapped PKCS#8 bytes
|
||||
salt: ArrayBuffer; // PBKDF2 salt
|
||||
iv: ArrayBuffer; // AES-GCM IV
|
||||
kdfIterations: number;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
serialNumber: string;
|
||||
notBefore: string; // ISO 8601
|
||||
notAfter: string; // ISO 8601
|
||||
fingerprint: string; // SHA-256 hex of DER cert
|
||||
algorithm: string; // e.g. "RSA-2048", "RSA-4096", "ECDSA-P256"
|
||||
capabilities: SmimeKeyCapabilities;
|
||||
}
|
||||
|
||||
/** What a certificate can be used for based on KeyUsage/ExtendedKeyUsage. */
|
||||
export interface SmimeKeyCapabilities {
|
||||
canSign: boolean;
|
||||
canEncrypt: boolean;
|
||||
}
|
||||
|
||||
/** Runtime-only unlocked private key handle (never persisted). */
|
||||
export interface SmimeUnlockedKey {
|
||||
id: string;
|
||||
email: string;
|
||||
privateKey: CryptoKey; // imported as non-extractable
|
||||
}
|
||||
|
||||
/** A recipient or contact public certificate. */
|
||||
export interface SmimePublicCert {
|
||||
id: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509
|
||||
issuer: string;
|
||||
subject: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
fingerprint: string;
|
||||
source: 'manual' | 'contact' | 'signed-email';
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
/** Status of S/MIME processing for a single email message. */
|
||||
export interface SmimeStatus {
|
||||
isSigned: boolean;
|
||||
isEncrypted: boolean;
|
||||
signatureValid?: boolean;
|
||||
signatureError?: string;
|
||||
signerCert?: SmimePublicCert;
|
||||
signerEmailMatch?: boolean;
|
||||
decryptionSuccess?: boolean;
|
||||
decryptionError?: string;
|
||||
unsupportedReason?: string;
|
||||
}
|
||||
|
||||
/** Metadata extracted from a parsed X.509 certificate. */
|
||||
export interface CertificateInfo {
|
||||
subject: string;
|
||||
issuer: string;
|
||||
serialNumber: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
keyUsage?: string[];
|
||||
extendedKeyUsage?: string[];
|
||||
emailAddresses: string[];
|
||||
capabilities: SmimeKeyCapabilities;
|
||||
}
|
||||
|
||||
/** Result of PKCS#12 import parsing. */
|
||||
export interface Pkcs12ImportResult {
|
||||
keyRecord: SmimeKeyRecord;
|
||||
certInfo: CertificateInfo;
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Minimal TNEF (Transport Neutral Encapsulation Format) parser.
|
||||
*
|
||||
* Parses winmail.dat files sent by Microsoft Outlook to extract
|
||||
* the HTML body, plain text body, and embedded attachments.
|
||||
*
|
||||
* Reference: MS-OXTNEF / MS-TNEF specification.
|
||||
*/
|
||||
|
||||
// TNEF signature
|
||||
const TNEF_SIGNATURE = 0x223E9F78;
|
||||
|
||||
// Attribute levels
|
||||
const LVL_MESSAGE = 0x01;
|
||||
const LVL_ATTACHMENT = 0x02;
|
||||
|
||||
// Message-level attribute IDs
|
||||
const attBody = 0x0002800C;
|
||||
const attMAPIProps = 0x00069003;
|
||||
|
||||
// Attachment-level attribute IDs
|
||||
const attAttachRenddata = 0x00069002;
|
||||
const attAttachData = 0x0006800F;
|
||||
const attAttachTitle = 0x00018010;
|
||||
const attAttachment = 0x00069005; // MAPI props for attachments
|
||||
|
||||
// MAPI property types
|
||||
const PT_SHORT = 0x0002;
|
||||
const PT_LONG = 0x0003;
|
||||
const PT_BOOLEAN = 0x000B;
|
||||
const PT_STRING8 = 0x001E;
|
||||
const PT_UNICODE = 0x001F;
|
||||
const PT_BINARY = 0x0102;
|
||||
const PT_SYSTIME = 0x0040;
|
||||
const PT_CLSID = 0x0048;
|
||||
const PT_I8 = 0x0014;
|
||||
|
||||
// Multi-value flag
|
||||
const MV_FLAG = 0x1000;
|
||||
|
||||
// MAPI property IDs
|
||||
const PR_BODY = 0x1000;
|
||||
const PR_BODY_HTML = 0x1013;
|
||||
const PR_ATTACH_LONG_FILENAME = 0x3707;
|
||||
const PR_ATTACH_MIME_TAG = 0x370E;
|
||||
const PR_ATTACH_DATA_BIN = 0x3701;
|
||||
|
||||
export interface TnefAttachment {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface TnefResult {
|
||||
body: string | null;
|
||||
htmlBody: string | null;
|
||||
attachments: TnefAttachment[];
|
||||
}
|
||||
|
||||
class BinaryReader {
|
||||
private view: DataView;
|
||||
private offset: number;
|
||||
private bytes: Uint8Array;
|
||||
|
||||
constructor(data: Uint8Array) {
|
||||
this.bytes = data;
|
||||
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
readUint8(): number {
|
||||
const val = this.view.getUint8(this.offset);
|
||||
this.offset += 1;
|
||||
return val;
|
||||
}
|
||||
|
||||
readUint16LE(): number {
|
||||
const val = this.view.getUint16(this.offset, true);
|
||||
this.offset += 2;
|
||||
return val;
|
||||
}
|
||||
|
||||
readUint32LE(): number {
|
||||
const val = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return val;
|
||||
}
|
||||
|
||||
readBytes(length: number): Uint8Array {
|
||||
const slice = this.bytes.slice(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return slice;
|
||||
}
|
||||
|
||||
skip(n: number): void {
|
||||
this.offset += n;
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
return this.bytes.byteLength - this.offset;
|
||||
}
|
||||
}
|
||||
|
||||
/** Padding needed to align to 4-byte boundary */
|
||||
function pad4(len: number): number {
|
||||
return (4 - (len % 4)) % 4;
|
||||
}
|
||||
|
||||
/** Read a single MAPI property value (fixed-length types only) */
|
||||
function readMAPIFixedValue(r: BinaryReader, propType: number): Uint8Array | number | null {
|
||||
switch (propType) {
|
||||
case PT_SHORT: {
|
||||
const val = r.readUint16LE();
|
||||
r.skip(2); // padded to 4 bytes
|
||||
return val;
|
||||
}
|
||||
case PT_LONG:
|
||||
case PT_BOOLEAN:
|
||||
return r.readUint32LE();
|
||||
case PT_I8:
|
||||
case PT_SYSTIME:
|
||||
return r.readBytes(8);
|
||||
case PT_CLSID:
|
||||
return r.readBytes(16);
|
||||
default:
|
||||
// Unknown/unsupported type — try to read as fixed 4 bytes
|
||||
if (r.remaining >= 4) {
|
||||
return r.readBytes(4);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a variable-length MAPI value (length-prefixed with padding) */
|
||||
function readMAPIVarValue(r: BinaryReader): Uint8Array | null {
|
||||
if (r.remaining < 4) return null;
|
||||
const length = r.readUint32LE();
|
||||
if (length > r.remaining) return null;
|
||||
const data = r.readBytes(length);
|
||||
r.skip(pad4(length));
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Check if a base property type is variable-length */
|
||||
function isVarLengthType(baseType: number): boolean {
|
||||
return baseType === PT_STRING8 || baseType === PT_UNICODE || baseType === PT_BINARY;
|
||||
}
|
||||
|
||||
/** Decode a MAPI string (PT_STRING8 or PT_UNICODE) from raw bytes */
|
||||
function decodeMAPIString(data: Uint8Array, propType: number): string {
|
||||
if (propType === PT_UNICODE) {
|
||||
let len = data.byteLength;
|
||||
// Strip null terminator (2 bytes for UTF-16)
|
||||
if (len >= 2 && data[len - 1] === 0 && data[len - 2] === 0) {
|
||||
len -= 2;
|
||||
}
|
||||
return new TextDecoder('utf-16le').decode(data.subarray(0, len));
|
||||
}
|
||||
let len = data.byteLength;
|
||||
if (len >= 1 && data[len - 1] === 0) {
|
||||
len -= 1;
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(data.subarray(0, len));
|
||||
}
|
||||
|
||||
/** Parse MAPI properties from a raw attribute data block */
|
||||
function parseMAPIProps(data: Uint8Array): Map<number, { type: number; value: Uint8Array | number | null }> {
|
||||
const props = new Map<number, { type: number; value: Uint8Array | number | null }>();
|
||||
const r = new BinaryReader(data);
|
||||
|
||||
if (r.remaining < 4) return props;
|
||||
const count = r.readUint32LE();
|
||||
|
||||
for (let i = 0; i < count && r.remaining >= 4; i++) {
|
||||
const propType = r.readUint16LE();
|
||||
const propID = r.readUint16LE();
|
||||
|
||||
// Named properties (ID >= 0x8000) carry extra GUID + name data
|
||||
if (propID >= 0x8000) {
|
||||
if (r.remaining < 20) break;
|
||||
r.skip(16); // GUID
|
||||
const kind = r.readUint32LE();
|
||||
if (kind === 0) {
|
||||
if (r.remaining < 4) break;
|
||||
r.skip(4); // named-by-ID
|
||||
} else {
|
||||
if (r.remaining < 4) break;
|
||||
const nameLen = r.readUint32LE();
|
||||
if (nameLen > r.remaining) break;
|
||||
r.skip(nameLen);
|
||||
r.skip(pad4(nameLen));
|
||||
}
|
||||
}
|
||||
|
||||
const baseType = propType & 0x0FFF;
|
||||
const isMultiValue = (propType & MV_FLAG) !== 0;
|
||||
|
||||
if (isVarLengthType(baseType)) {
|
||||
// Variable-length types always have a value count (1 for single-value)
|
||||
if (r.remaining < 4) break;
|
||||
const valueCount = r.readUint32LE();
|
||||
let lastValue: Uint8Array | null = null;
|
||||
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
||||
lastValue = readMAPIVarValue(r);
|
||||
}
|
||||
if (!isMultiValue && lastValue) {
|
||||
props.set(propID, { type: propType, value: lastValue });
|
||||
}
|
||||
} else if (isMultiValue) {
|
||||
if (r.remaining < 4) break;
|
||||
const valueCount = r.readUint32LE();
|
||||
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
||||
readMAPIFixedValue(r, baseType);
|
||||
}
|
||||
} else {
|
||||
const value = readMAPIFixedValue(r, baseType);
|
||||
props.set(propID, { type: propType, value });
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a TNEF (winmail.dat) file and extract the body and attachments.
|
||||
*
|
||||
* @param data - Raw bytes of the TNEF file
|
||||
* @returns Parsed result with body text, HTML body, and attachments
|
||||
*/
|
||||
export function parseTnef(data: Uint8Array): TnefResult {
|
||||
const result: TnefResult = {
|
||||
body: null,
|
||||
htmlBody: null,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
if (data.byteLength < 6) return result;
|
||||
|
||||
const r = new BinaryReader(data);
|
||||
|
||||
const signature = r.readUint32LE();
|
||||
if (signature !== TNEF_SIGNATURE) {
|
||||
return result;
|
||||
}
|
||||
|
||||
r.skip(2); // legacy key
|
||||
|
||||
// Current attachment being assembled
|
||||
let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null;
|
||||
|
||||
while (r.remaining >= 11) {
|
||||
const level = r.readUint8();
|
||||
const attrID = r.readUint32LE();
|
||||
const attrLen = r.readUint32LE();
|
||||
|
||||
if (attrLen > r.remaining - 2) break; // not enough data for payload + checksum
|
||||
|
||||
const attrData = r.readBytes(attrLen);
|
||||
r.skip(2); // checksum
|
||||
|
||||
if (level === LVL_MESSAGE) {
|
||||
if (attrID === attBody) {
|
||||
result.body = new TextDecoder('utf-8').decode(attrData);
|
||||
} else if (attrID === attMAPIProps) {
|
||||
const props = parseMAPIProps(attrData);
|
||||
|
||||
// HTML body
|
||||
const htmlProp = props.get(PR_BODY_HTML);
|
||||
if (htmlProp?.value instanceof Uint8Array) {
|
||||
const baseType = htmlProp.type & 0x0FFF;
|
||||
if (baseType === PT_STRING8 || baseType === PT_UNICODE) {
|
||||
result.htmlBody = decodeMAPIString(htmlProp.value, baseType);
|
||||
} else {
|
||||
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Plain text body from MAPI props (fallback)
|
||||
if (!result.body) {
|
||||
const bodyProp = props.get(PR_BODY);
|
||||
if (bodyProp?.value instanceof Uint8Array) {
|
||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (level === LVL_ATTACHMENT) {
|
||||
if (attrID === attAttachRenddata) {
|
||||
// Start of a new attachment — flush previous
|
||||
if (curAttach?.data) {
|
||||
result.attachments.push({
|
||||
name: curAttach.name,
|
||||
mimeType: curAttach.mimeType,
|
||||
data: curAttach.data,
|
||||
});
|
||||
}
|
||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||
} else if (attrID === attAttachTitle && curAttach) {
|
||||
let len = attrData.byteLength;
|
||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
||||
} else if (attrID === attAttachData && curAttach) {
|
||||
curAttach.data = attrData;
|
||||
} else if (attrID === attAttachment && curAttach) {
|
||||
const props = parseMAPIProps(attrData);
|
||||
|
||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||
if (longName?.value instanceof Uint8Array) {
|
||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
||||
}
|
||||
|
||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||
if (mimeTag?.value instanceof Uint8Array) {
|
||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
||||
}
|
||||
|
||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||
if (attachData?.value instanceof Uint8Array) {
|
||||
curAttach.data = attachData.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush last attachment
|
||||
if (curAttach?.data) {
|
||||
result.attachments.push({
|
||||
name: curAttach.name,
|
||||
mimeType: curAttach.mimeType,
|
||||
data: curAttach.data,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME attachment is a TNEF (winmail.dat) file.
|
||||
*/
|
||||
export function isTnefAttachment(name?: string | null, type?: string): boolean {
|
||||
const lowerName = (name || '').toLowerCase();
|
||||
const lowerType = (type || '').toLowerCase();
|
||||
return (
|
||||
lowerName === 'winmail.dat' ||
|
||||
lowerType === 'application/ms-tnef' ||
|
||||
lowerType === 'application/vnd.ms-tnef'
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,42 @@ export function formatDate(date: Date | string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date/time string respecting the user's 12h/24h time format preference.
|
||||
*/
|
||||
export function formatDateTime(
|
||||
date: Date | string,
|
||||
timeFormat: '12h' | '24h',
|
||||
options?: {
|
||||
weekday?: 'short' | 'long';
|
||||
year?: 'numeric';
|
||||
month?: 'short' | 'long';
|
||||
day?: 'numeric';
|
||||
second?: '2-digit';
|
||||
timeZoneName?: 'short';
|
||||
dateOnly?: boolean;
|
||||
}
|
||||
): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (isNaN(d.getTime())) return typeof date === 'string' ? date : '';
|
||||
|
||||
const localeOptions: Intl.DateTimeFormatOptions = {};
|
||||
if (options?.weekday) localeOptions.weekday = options.weekday;
|
||||
if (options?.year) localeOptions.year = options.year;
|
||||
if (options?.month) localeOptions.month = options.month;
|
||||
if (options?.day) localeOptions.day = options.day;
|
||||
|
||||
if (!options?.dateOnly) {
|
||||
localeOptions.hour = '2-digit';
|
||||
localeOptions.minute = '2-digit';
|
||||
localeOptions.hour12 = timeFormat === '12h';
|
||||
if (options?.second) localeOptions.second = options.second;
|
||||
if (options?.timeZoneName) localeOptions.timeZoneName = options.timeZoneName;
|
||||
}
|
||||
|
||||
return d.toLocaleString(undefined, localeOptions);
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength).trim() + "...";
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||
"tags": "Tags",
|
||||
"mail": "E-Mail",
|
||||
"nav_label": "Navigation"
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar-Apps",
|
||||
"add_new": "App hinzufügen",
|
||||
"edit_app": "App bearbeiten",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Meine App",
|
||||
"name_required": "Name ist erforderlich",
|
||||
"url_label": "URL",
|
||||
"url_required": "URL ist erforderlich",
|
||||
"url_invalid": "Geben Sie eine gültige http- oder https-URL ein",
|
||||
"icon_label": "Symbol",
|
||||
"icon_required": "Symbol ist erforderlich",
|
||||
"open_mode_label": "Öffnungsmodus",
|
||||
"open_new_tab": "Neuer Tab",
|
||||
"open_inline": "Eingebettet",
|
||||
"cancel": "Abbrechen",
|
||||
"add": "Hinzufügen",
|
||||
"update": "Aktualisieren",
|
||||
"delete": "Löschen",
|
||||
"delete_confirm_title": "App löschen",
|
||||
"delete_confirm": "Möchten Sie \"{name}\" wirklich löschen?",
|
||||
"no_apps": "Noch keine Apps hinzugefügt",
|
||||
"no_apps_hint": "Fügen Sie benutzerdefinierte Apps und Links zur Seitenleiste hinzu",
|
||||
"search_icons": "Symbole suchen...",
|
||||
"show_popular": "Beliebt",
|
||||
"show_all": "Alle",
|
||||
"no_icons_found": "Keine Symbole gefunden",
|
||||
"inline_badge": "Eingebettet",
|
||||
"tab_badge": "Tab"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Keine Nachrichten gefunden",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "Hochladen abbrechen",
|
||||
"upload_failed": "Hochladen von {filename} fehlgeschlagen",
|
||||
"send_failed": "E-Mail konnte nicht gesendet werden",
|
||||
"smime_sign_on": "S/MIME-Signatur aktiviert",
|
||||
"smime_sign_off": "S/MIME-Signatur deaktiviert",
|
||||
"smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert",
|
||||
"smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert",
|
||||
"smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich: {reason}",
|
||||
"smime_unlock_title": "S/MIME-Schlüssel entsperren",
|
||||
"smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.",
|
||||
"smime_unlock_button": "Entsperren",
|
||||
"smime_passphrase_placeholder": "S/MIME-Passphrase",
|
||||
"continue_draft": "Entwurf fortsetzen",
|
||||
"close_draft_title": "Entwurf speichern oder verwerfen?",
|
||||
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "Ordner",
|
||||
"keywords": "Schlüsselwörter",
|
||||
"security": "Sicherheit",
|
||||
"encryption": "Verschlüsselung",
|
||||
"files": "Dateien",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "Sidebar-Apps"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Allgemein",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Dauerhaft löschen",
|
||||
"warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Nachrichten archivieren in",
|
||||
"description": "Wie E-Mails beim Archivieren organisiert werden",
|
||||
"single": "Einem einzigen Ordner",
|
||||
"year": "Einem Ordner pro Jahr",
|
||||
"month": "Einem Ordner pro Monat",
|
||||
"reorganize": "Bestehendes Archiv umorganisieren",
|
||||
"reorganize_success": "{count, plural, =0 {Keine E-Mails umzuorganisieren} =1 {1 E-Mail umorganisiert} other {# E-Mails umorganisiert}}",
|
||||
"reorganize_error": "Archiv konnte nicht umorganisiert werden"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Spam dauerhaft löschen",
|
||||
"description": "E-Mails aus dem Spam-Ordner dauerhaft löschen, anstatt sie in den Papierkorb zu verschieben"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "E-Mails pro Seite",
|
||||
"description": "Anzahl der E-Mails, die auf einmal geladen werden"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "E-Mails immer im Hellmodus anzeigen",
|
||||
"description": "E-Mail-Inhalte im Hellmodus darstellen, auch wenn die App im Dunkelmodus ist, um Probleme bei der Dunkelmodus-Konvertierung zu vermeiden"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Externe Inhalte",
|
||||
"description": "Wie mit Bildern und externen Inhalten umgegangen werden soll",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Importieren"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "Sidebar-Apps",
|
||||
"description": "Benutzerdefinierte Apps und Links in der Seitenleiste verwalten",
|
||||
"keep_loaded": "Apps geladen halten",
|
||||
"keep_loaded_description": "Inline-Apps beim Wechsel im Hintergrund laufen lassen, um Neuladen zu vermeiden",
|
||||
"manage_title": "Benutzerdefinierte Apps",
|
||||
"manage_description": "Apps in der Seitenleiste hinzufügen, bearbeiten oder entfernen"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "Kalender",
|
||||
"calendar_uri": "Kalender-URL",
|
||||
"scheduling_uri": "Terminplanungs-URL",
|
||||
"freebusy_uri": "Frei/Belegt-URL"
|
||||
"freebusy_uri": "Frei/Belegt-URL",
|
||||
"cert_issuer": "Aussteller",
|
||||
"cert_expires": "Läuft ab",
|
||||
"cert_expired": "Abgelaufen",
|
||||
"cert_fingerprint": "Fingerabdruck",
|
||||
"cert_algorithm": "Algorithmus",
|
||||
"import_to_smime": "In S/MIME importieren",
|
||||
"cert_already_imported": "Zertifikat bereits importiert",
|
||||
"cert_imported": "Zertifikat importiert",
|
||||
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Neuer Kontakt",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ihre Zertifikate",
|
||||
"your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails",
|
||||
"recipient_certificates": "Empfängerzertifikate",
|
||||
"recipient_certificates_desc": "Öffentliche Zertifikate zum Verschlüsseln von E-Mails an Empfänger",
|
||||
"identity_bindings": "Identitäts-Zertifikatsbindungen",
|
||||
"identity_bindings_desc": "Ordnen Sie S/MIME-Zertifikate Ihren E-Mail-Identitäten zu",
|
||||
"defaults_title": "Standardwerte",
|
||||
"defaults_desc": "Konfigurieren Sie das Standardverhalten für Signatur und Verschlüsselung",
|
||||
"import_pkcs12": "PKCS#12 importieren (.p12/.pfx)",
|
||||
"import_public_cert": "Zertifikat importieren",
|
||||
"no_certificates": "Noch keine Zertifikate importiert",
|
||||
"no_recipient_certs": "Keine Empfängerzertifikate",
|
||||
"expires": "Läuft ab",
|
||||
"expired": "Abgelaufen",
|
||||
"bound_to": "Zugeordnet zu",
|
||||
"no_key_bound": "Keine",
|
||||
"lock": "Schlüssel sperren",
|
||||
"unlock": "Schlüssel entsperren",
|
||||
"details": "Details anzeigen",
|
||||
"delete": "Löschen",
|
||||
"encrypt_by_default": "Standardmäßig verschlüsseln",
|
||||
"encrypt_by_default_desc": "E-Mails automatisch verschlüsseln, wenn alle Empfänger Zertifikate haben",
|
||||
"remember_unlocked": "Entsperrte Schlüssel merken",
|
||||
"remember_unlocked_desc": "Schlüssel für die Dauer dieser Browsersitzung entsperrt lassen",
|
||||
"sign_default_for": "Standardmäßig signieren für",
|
||||
"enter_p12_passphrase": "PKCS#12-Passphrase eingeben",
|
||||
"p12_passphrase_desc": "Geben Sie die Passphrase ein, die diese Zertifikatsdatei schützt",
|
||||
"enter_storage_passphrase": "Speicher-Passphrase festlegen",
|
||||
"storage_passphrase_desc": "Wählen Sie eine Passphrase, um diesen Schlüssel im Browser zu schützen",
|
||||
"next": "Weiter",
|
||||
"import": "Importieren",
|
||||
"unlock_key": "Schlüssel entsperren",
|
||||
"unlock_key_desc": "Geben Sie die Speicher-Passphrase ein, um diesen Schlüssel zum Signieren oder Entschlüsseln zu entsperren",
|
||||
"passphrase_placeholder": "Passphrase eingeben",
|
||||
"confirm_passphrase_placeholder": "Passphrase bestätigen",
|
||||
"passphrase_mismatch": "Passphrasen stimmen nicht überein",
|
||||
"cancel": "Abbrechen",
|
||||
"processing": "Wird verarbeitet…",
|
||||
"close": "Schließen",
|
||||
"certificate_details": "Zertifikatsdetails",
|
||||
"cert_subject": "Betreff",
|
||||
"cert_issuer": "Aussteller",
|
||||
"cert_email": "E-Mail",
|
||||
"cert_serial": "Seriennummer",
|
||||
"cert_validity": "Gültigkeit",
|
||||
"cert_fingerprint": "Fingerabdruck (SHA-256)",
|
||||
"cert_algorithm": "Algorithmus",
|
||||
"cert_capabilities": "Fähigkeiten",
|
||||
"cert_source": "Quelle",
|
||||
"cert_expired": "Dieses Zertifikat ist abgelaufen",
|
||||
"cert_not_yet_valid": "Dieses Zertifikat ist noch nicht gültig",
|
||||
"cap_sign": "Signieren",
|
||||
"cap_encrypt": "Verschlüsselung",
|
||||
"cap_none": "Keine",
|
||||
"show_passphrase": "Passphrase anzeigen",
|
||||
"hide_passphrase": "Passphrase ausblenden",
|
||||
"sign_toggle": "Signieren",
|
||||
"encrypt_toggle": "Verschlüsseln",
|
||||
"missing_recipient_certs": "Fehlende Zertifikate für: {emails}",
|
||||
"missing_sender_cert": "Diesem Konto ist kein Zertifikat zugeordnet",
|
||||
"status_encrypted_ok": "Diese Nachricht wurde verschlüsselt",
|
||||
"status_encrypted_no_key": "Diese Nachricht ist verschlüsselt, aber es wurde kein passender Schlüssel gefunden",
|
||||
"status_encrypted_failed": "Diese Nachricht konnte nicht entschlüsselt werden",
|
||||
"status_signed_valid": "Signatur überprüft",
|
||||
"status_signed_invalid": "Signaturprüfung fehlgeschlagen",
|
||||
"status_signed_expired_cert": "Mit einem abgelaufenen Zertifikat signiert",
|
||||
"status_signed_mismatch": "Signatur ist gültig, aber der Unterzeichner stimmt nicht mit dem Absender überein",
|
||||
"status_unsupported": "Nicht unterstütztes S/MIME-Format",
|
||||
"auto_import_signer_certs": "Unterzeichnerzertifikate automatisch importieren",
|
||||
"auto_import_signer_certs_desc": "Zertifikate aus verifizierten signierten E-Mails automatisch für spätere Verschlüsselung speichern",
|
||||
"export": "Exportieren",
|
||||
"enter_export_passphrase": "Export-Passphrase festlegen",
|
||||
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
|
||||
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
|
||||
"incorrect_passphrase": "Falsche Passphrase"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Vacation responder is active",
|
||||
"tags": "Tags",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation"
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar Apps",
|
||||
"add_new": "Add App",
|
||||
"edit_app": "Edit App",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "My App",
|
||||
"name_required": "Name is required",
|
||||
"url_label": "URL",
|
||||
"url_required": "URL is required",
|
||||
"url_invalid": "Enter a valid http or https URL",
|
||||
"icon_label": "Icon",
|
||||
"icon_required": "Icon is required",
|
||||
"open_mode_label": "Open Mode",
|
||||
"open_new_tab": "New Tab",
|
||||
"open_inline": "Inline",
|
||||
"cancel": "Cancel",
|
||||
"add": "Add",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"delete_confirm_title": "Delete App",
|
||||
"delete_confirm": "Are you sure you want to delete \"{name}\"?",
|
||||
"no_apps": "No apps added yet",
|
||||
"no_apps_hint": "Add custom apps and links to your sidebar",
|
||||
"search_icons": "Search icons...",
|
||||
"show_popular": "Popular",
|
||||
"show_all": "All",
|
||||
"no_icons_found": "No icons found",
|
||||
"inline_badge": "Inline",
|
||||
"tab_badge": "Tab"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "No messages found",
|
||||
@@ -421,7 +452,16 @@
|
||||
"continue_draft": "Continue draft",
|
||||
"close_draft_title": "Save or discard draft?",
|
||||
"close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?",
|
||||
"save_draft": "Save Draft"
|
||||
"save_draft": "Save Draft",
|
||||
"smime_sign_on": "S/MIME signing enabled",
|
||||
"smime_sign_off": "Enable S/MIME signing",
|
||||
"smime_encrypt_on": "S/MIME encryption enabled",
|
||||
"smime_encrypt_off": "Enable S/MIME encryption",
|
||||
"smime_encrypt_unavailable": "S/MIME encryption unavailable – missing recipient certificates",
|
||||
"smime_unlock_title": "Unlock S/MIME Key",
|
||||
"smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.",
|
||||
"smime_unlock_button": "Unlock",
|
||||
"smime_passphrase_placeholder": "Passphrase"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirm",
|
||||
@@ -553,7 +593,9 @@
|
||||
"keywords": "Keywords",
|
||||
"security": "Security",
|
||||
"files": "Files",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"encryption": "Encryption",
|
||||
"sidebar_apps": "Sidebar Apps"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "General",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Delete Permanently",
|
||||
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Archive in",
|
||||
"description": "How to organize emails when archiving",
|
||||
"single": "A single folder",
|
||||
"year": "A folder per year",
|
||||
"month": "A folder per month",
|
||||
"reorganize": "Reorganize existing archive",
|
||||
"reorganize_success": "{count, plural, =0 {No emails to reorganize} =1 {1 email reorganized} other {# emails reorganized}}",
|
||||
"reorganize_error": "Failed to reorganize archive"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Permanently Delete Junk",
|
||||
"description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "Emails Per Page",
|
||||
"description": "Number of emails to load at once"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "Always Show Emails in Light Mode",
|
||||
"description": "Render email content in light mode even when the app is in dark mode, avoiding dark mode conversion issues"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "External Content",
|
||||
"description": "How to handle images and external content",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Import"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "Sidebar Apps",
|
||||
"description": "Manage custom apps and links in your sidebar",
|
||||
"keep_loaded": "Keep Apps Loaded",
|
||||
"keep_loaded_description": "Keep inline apps running in the background when switching between them to avoid reloading",
|
||||
"manage_title": "Custom Apps",
|
||||
"manage_description": "Add, edit, or remove custom apps from your sidebar"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1408,6 +1472,15 @@
|
||||
"categories": "Categories",
|
||||
"related_contacts": "Related Contacts",
|
||||
"crypto_keys": "Crypto Keys",
|
||||
"cert_issuer": "Issuer",
|
||||
"cert_expires": "Expires",
|
||||
"cert_expired": "Expired",
|
||||
"cert_fingerprint": "Fingerprint",
|
||||
"cert_algorithm": "Algorithm",
|
||||
"import_to_smime": "Import to S/MIME",
|
||||
"cert_already_imported": "Already imported to S/MIME",
|
||||
"cert_imported": "Certificate imported to S/MIME store",
|
||||
"cert_import_failed": "Failed to import certificate",
|
||||
"no_contact_selected": "Select a contact to view details",
|
||||
"compose_email": "Compose email",
|
||||
"copy_email": "Copy email",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Your Certificates",
|
||||
"your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails",
|
||||
"recipient_certificates": "Recipient Certificates",
|
||||
"recipient_certificates_desc": "Public certificates for encrypting emails to recipients",
|
||||
"identity_bindings": "Identity Key Bindings",
|
||||
"identity_bindings_desc": "Bind S/MIME certificates to your email identities",
|
||||
"defaults_title": "Defaults",
|
||||
"defaults_desc": "Configure default signing and encryption behavior",
|
||||
"import_pkcs12": "Import PKCS#12 (.p12/.pfx)",
|
||||
"import_public_cert": "Import Certificate",
|
||||
"no_certificates": "No certificates imported yet",
|
||||
"no_recipient_certs": "No recipient certificates",
|
||||
"expires": "Expires",
|
||||
"expired": "Expired",
|
||||
"bound_to": "Bound to",
|
||||
"no_key_bound": "None",
|
||||
"lock": "Lock key",
|
||||
"unlock": "Unlock key",
|
||||
"details": "View details",
|
||||
"delete": "Delete",
|
||||
"encrypt_by_default": "Encrypt by default",
|
||||
"encrypt_by_default_desc": "Automatically encrypt emails when all recipients have certificates",
|
||||
"remember_unlocked": "Remember unlocked keys",
|
||||
"remember_unlocked_desc": "Keep keys unlocked for the duration of this browser session",
|
||||
"sign_default_for": "Sign by default for",
|
||||
"enter_p12_passphrase": "Enter PKCS#12 Passphrase",
|
||||
"p12_passphrase_desc": "Enter the passphrase that protects this certificate file",
|
||||
"enter_storage_passphrase": "Set Storage Passphrase",
|
||||
"storage_passphrase_desc": "Choose a passphrase to protect this key at rest in your browser",
|
||||
"next": "Next",
|
||||
"import": "Import",
|
||||
"unlock_key": "Unlock Key",
|
||||
"unlock_key_desc": "Enter the storage passphrase to unlock this key for signing or decryption",
|
||||
"passphrase_placeholder": "Enter passphrase",
|
||||
"confirm_passphrase_placeholder": "Confirm passphrase",
|
||||
"passphrase_mismatch": "Passphrases do not match",
|
||||
"cancel": "Cancel",
|
||||
"processing": "Processing…",
|
||||
"close": "Close",
|
||||
"certificate_details": "Certificate Details",
|
||||
"cert_subject": "Subject",
|
||||
"cert_issuer": "Issuer",
|
||||
"cert_email": "Email",
|
||||
"cert_serial": "Serial Number",
|
||||
"cert_validity": "Validity",
|
||||
"cert_fingerprint": "Fingerprint (SHA-256)",
|
||||
"cert_algorithm": "Algorithm",
|
||||
"cert_capabilities": "Capabilities",
|
||||
"cert_source": "Source",
|
||||
"cert_expired": "This certificate has expired",
|
||||
"cert_not_yet_valid": "This certificate is not yet valid",
|
||||
"cap_sign": "Signing",
|
||||
"cap_encrypt": "Encryption",
|
||||
"cap_none": "None",
|
||||
"show_passphrase": "Show passphrase",
|
||||
"hide_passphrase": "Hide passphrase",
|
||||
"sign_toggle": "Sign",
|
||||
"encrypt_toggle": "Encrypt",
|
||||
"missing_recipient_certs": "Missing certificates for: {emails}",
|
||||
"missing_sender_cert": "No certificate bound to this identity",
|
||||
"status_encrypted_ok": "This message was encrypted",
|
||||
"status_encrypted_no_key": "This message is encrypted but no matching key was found",
|
||||
"status_encrypted_failed": "Failed to decrypt this message",
|
||||
"status_signed_valid": "Signature verified",
|
||||
"status_signed_invalid": "Signature verification failed",
|
||||
"status_signed_expired_cert": "Signed with an expired certificate",
|
||||
"status_signed_mismatch": "Signature valid, but signer does not match sender",
|
||||
"status_unsupported": "Unsupported S/MIME format",
|
||||
"auto_import_signer_certs": "Auto-import signer certificates",
|
||||
"auto_import_signer_certs_desc": "Automatically save certificates from verified signed emails for future encryption",
|
||||
"export": "Export",
|
||||
"enter_export_passphrase": "Set Export Passphrase",
|
||||
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
|
||||
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
|
||||
"incorrect_passphrase": "Incorrect passphrase"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Respuesta automática activa",
|
||||
"tags": "Etiquetas",
|
||||
"mail": "Correo",
|
||||
"nav_label": "Navegación"
|
||||
"nav_label": "Navegación",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplicaciones de la barra lateral",
|
||||
"add_new": "Añadir aplicación",
|
||||
"edit_app": "Editar aplicación",
|
||||
"name_label": "Nombre",
|
||||
"name_placeholder": "Mi aplicación",
|
||||
"name_required": "El nombre es obligatorio",
|
||||
"url_label": "URL",
|
||||
"url_required": "La URL es obligatoria",
|
||||
"url_invalid": "Introduce una URL http o https válida",
|
||||
"icon_label": "Icono",
|
||||
"icon_required": "El icono es obligatorio",
|
||||
"open_mode_label": "Modo de apertura",
|
||||
"open_new_tab": "Nueva pestaña",
|
||||
"open_inline": "Integrado",
|
||||
"cancel": "Cancelar",
|
||||
"add": "Añadir",
|
||||
"update": "Actualizar",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm_title": "Eliminar aplicación",
|
||||
"delete_confirm": "¿Estás seguro de que quieres eliminar \"{name}\"?",
|
||||
"no_apps": "No se han añadido aplicaciones",
|
||||
"no_apps_hint": "Añade aplicaciones y enlaces personalizados a tu barra lateral",
|
||||
"search_icons": "Buscar iconos...",
|
||||
"show_popular": "Populares",
|
||||
"show_all": "Todos",
|
||||
"no_icons_found": "No se encontraron iconos",
|
||||
"inline_badge": "Integrado",
|
||||
"tab_badge": "Pestaña"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "No se encontraron mensajes",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "Cancelar subida",
|
||||
"upload_failed": "Error al subir {filename}",
|
||||
"send_failed": "Error al enviar el correo",
|
||||
"smime_sign_on": "Firma S/MIME activada",
|
||||
"smime_sign_off": "Firma S/MIME desactivada",
|
||||
"smime_encrypt_on": "Cifrado S/MIME activado",
|
||||
"smime_encrypt_off": "Cifrado S/MIME desactivado",
|
||||
"smime_encrypt_unavailable": "El cifrado S/MIME no está disponible: {reason}",
|
||||
"smime_unlock_title": "Desbloquear clave S/MIME",
|
||||
"smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.",
|
||||
"smime_unlock_button": "Desbloquear",
|
||||
"smime_passphrase_placeholder": "Contraseña S/MIME",
|
||||
"continue_draft": "Continuar borrador",
|
||||
"close_draft_title": "¿Guardar o descartar borrador?",
|
||||
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "Carpetas",
|
||||
"keywords": "Palabras clave",
|
||||
"security": "Seguridad",
|
||||
"encryption": "Cifrado",
|
||||
"files": "Archivos",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "Apps de barra lateral"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "General",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Eliminar Permanentemente",
|
||||
"warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Archivar en",
|
||||
"description": "Cómo organizar los correos al archivar",
|
||||
"single": "Una sola carpeta",
|
||||
"year": "Una carpeta por año",
|
||||
"month": "Una carpeta por mes",
|
||||
"reorganize": "Reorganizar archivo existente",
|
||||
"reorganize_success": "{count, plural, =0 {No hay correos para reorganizar} =1 {1 correo reorganizado} other {# correos reorganizados}}",
|
||||
"reorganize_error": "Error al reorganizar el archivo"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Eliminar spam permanentemente",
|
||||
"description": "Eliminar permanentemente los correos de la carpeta Spam en lugar de moverlos a la Papelera"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "Correos por Página",
|
||||
"description": "Número de correos a cargar a la vez"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "Mostrar correos siempre en modo claro",
|
||||
"description": "Mostrar el contenido del correo en modo claro incluso cuando la aplicación está en modo oscuro, evitando problemas de conversión"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Contenido Externo",
|
||||
"description": "Cómo manejar imágenes y contenido externo",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Importar"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "Apps de barra lateral",
|
||||
"description": "Gestionar aplicaciones y enlaces personalizados en la barra lateral",
|
||||
"keep_loaded": "Mantener apps cargadas",
|
||||
"keep_loaded_description": "Mantener las apps en línea ejecutándose en segundo plano al cambiar entre ellas para evitar recargas",
|
||||
"manage_title": "Apps personalizadas",
|
||||
"manage_description": "Agregar, editar o eliminar apps personalizadas de la barra lateral"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "Calendario",
|
||||
"calendar_uri": "URL del calendario",
|
||||
"scheduling_uri": "URL de programación",
|
||||
"freebusy_uri": "URL de disponibilidad"
|
||||
"freebusy_uri": "URL de disponibilidad",
|
||||
"cert_issuer": "Emisor",
|
||||
"cert_expires": "Caduca",
|
||||
"cert_expired": "Caducado",
|
||||
"cert_fingerprint": "Huella digital",
|
||||
"cert_algorithm": "Algoritmo",
|
||||
"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"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuevo contacto",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Tus certificados",
|
||||
"your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos",
|
||||
"recipient_certificates": "Certificados de destinatarios",
|
||||
"recipient_certificates_desc": "Certificados públicos para cifrar correos a destinatarios",
|
||||
"identity_bindings": "Vinculaciones de identidad y clave",
|
||||
"identity_bindings_desc": "Vincula certificados S/MIME a tus identidades de correo",
|
||||
"defaults_title": "Valores predeterminados",
|
||||
"defaults_desc": "Configura el comportamiento predeterminado de firma y cifrado",
|
||||
"import_pkcs12": "Importar PKCS#12 (.p12/.pfx)",
|
||||
"import_public_cert": "Importar certificado",
|
||||
"no_certificates": "Aún no se han importado certificados",
|
||||
"no_recipient_certs": "No hay certificados de destinatarios",
|
||||
"expires": "Caduca",
|
||||
"expired": "Caducado",
|
||||
"bound_to": "Vinculado a",
|
||||
"no_key_bound": "Ninguno",
|
||||
"lock": "Bloquear clave",
|
||||
"unlock": "Desbloquear clave",
|
||||
"details": "Ver detalles",
|
||||
"delete": "Eliminar",
|
||||
"encrypt_by_default": "Cifrar por defecto",
|
||||
"encrypt_by_default_desc": "Cifrar correos automáticamente cuando todos los destinatarios tengan certificados",
|
||||
"remember_unlocked": "Recordar claves desbloqueadas",
|
||||
"remember_unlocked_desc": "Mantener las claves desbloqueadas durante esta sesión del navegador",
|
||||
"sign_default_for": "Firmar por defecto para",
|
||||
"enter_p12_passphrase": "Introducir la contraseña de PKCS#12",
|
||||
"p12_passphrase_desc": "Introduce la contraseña que protege este archivo de certificado",
|
||||
"enter_storage_passphrase": "Definir contraseña de almacenamiento",
|
||||
"storage_passphrase_desc": "Elige una contraseña para proteger esta clave en tu navegador",
|
||||
"next": "Siguiente",
|
||||
"import": "Importar",
|
||||
"unlock_key": "Desbloquear clave",
|
||||
"unlock_key_desc": "Introduce la contraseña de almacenamiento para desbloquear esta clave para firmar o descifrar",
|
||||
"passphrase_placeholder": "Introduce la contraseña",
|
||||
"confirm_passphrase_placeholder": "Confirma la contraseña",
|
||||
"passphrase_mismatch": "Las contraseñas no coinciden",
|
||||
"cancel": "Cancelar",
|
||||
"processing": "Procesando…",
|
||||
"close": "Cerrar",
|
||||
"certificate_details": "Detalles del certificado",
|
||||
"cert_subject": "Asunto",
|
||||
"cert_issuer": "Emisor",
|
||||
"cert_email": "Correo electrónico",
|
||||
"cert_serial": "Número de serie",
|
||||
"cert_validity": "Validez",
|
||||
"cert_fingerprint": "Huella digital (SHA-256)",
|
||||
"cert_algorithm": "Algoritmo",
|
||||
"cert_capabilities": "Capacidades",
|
||||
"cert_source": "Origen",
|
||||
"cert_expired": "Este certificado ha caducado",
|
||||
"cert_not_yet_valid": "Este certificado aún no es válido",
|
||||
"cap_sign": "Firma",
|
||||
"cap_encrypt": "Cifrado",
|
||||
"cap_none": "Ninguno",
|
||||
"show_passphrase": "Mostrar contraseña",
|
||||
"hide_passphrase": "Ocultar contraseña",
|
||||
"sign_toggle": "Firmar",
|
||||
"encrypt_toggle": "Cifrar",
|
||||
"missing_recipient_certs": "Faltan certificados para: {emails}",
|
||||
"missing_sender_cert": "No hay un certificado vinculado a esta identidad",
|
||||
"status_encrypted_ok": "Este mensaje fue cifrado",
|
||||
"status_encrypted_no_key": "Este mensaje está cifrado, pero no se encontró una clave coincidente",
|
||||
"status_encrypted_failed": "No se pudo descifrar este mensaje",
|
||||
"status_signed_valid": "Firma verificada",
|
||||
"status_signed_invalid": "La verificación de la firma falló",
|
||||
"status_signed_expired_cert": "Firmado con un certificado caducado",
|
||||
"status_signed_mismatch": "La firma es válida, pero el firmante no coincide con el remitente",
|
||||
"status_unsupported": "Formato S/MIME no compatible",
|
||||
"auto_import_signer_certs": "Importar automáticamente certificados de firmantes",
|
||||
"auto_import_signer_certs_desc": "Guardar automáticamente certificados de correos firmados verificados para futuros cifrados",
|
||||
"export": "Exportar",
|
||||
"enter_export_passphrase": "Definir contraseña de exportación",
|
||||
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
|
||||
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
|
||||
"incorrect_passphrase": "Contraseña incorrecta"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Répondeur d'absence activé",
|
||||
"tags": "Étiquettes",
|
||||
"mail": "Messagerie",
|
||||
"nav_label": "Navigation"
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Applications de la barre latérale",
|
||||
"add_new": "Ajouter une application",
|
||||
"edit_app": "Modifier l'application",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "Mon application",
|
||||
"name_required": "Le nom est requis",
|
||||
"url_label": "URL",
|
||||
"url_required": "L'URL est requise",
|
||||
"url_invalid": "Entrez une URL http ou https valide",
|
||||
"icon_label": "Icône",
|
||||
"icon_required": "L'icône est requise",
|
||||
"open_mode_label": "Mode d'ouverture",
|
||||
"open_new_tab": "Nouvel onglet",
|
||||
"open_inline": "Intégré",
|
||||
"cancel": "Annuler",
|
||||
"add": "Ajouter",
|
||||
"update": "Mettre à jour",
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm_title": "Supprimer l'application",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?",
|
||||
"no_apps": "Aucune application ajoutée",
|
||||
"no_apps_hint": "Ajoutez des applications et liens personnalisés à votre barre latérale",
|
||||
"search_icons": "Rechercher des icônes...",
|
||||
"show_popular": "Populaires",
|
||||
"show_all": "Toutes",
|
||||
"no_icons_found": "Aucune icône trouvée",
|
||||
"inline_badge": "Intégré",
|
||||
"tab_badge": "Onglet"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Aucun message trouvé",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "Annuler l'envoi",
|
||||
"upload_failed": "Échec du téléversement de {filename}",
|
||||
"send_failed": "Échec de l'envoi de l'e-mail",
|
||||
"smime_sign_on": "Signature S/MIME activée",
|
||||
"smime_sign_off": "Signature S/MIME désactivée",
|
||||
"smime_encrypt_on": "Chiffrement S/MIME activé",
|
||||
"smime_encrypt_off": "Chiffrement S/MIME désactivé",
|
||||
"smime_encrypt_unavailable": "Le chiffrement S/MIME n'est pas disponible : {reason}",
|
||||
"smime_unlock_title": "Déverrouiller la clé S/MIME",
|
||||
"smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.",
|
||||
"smime_unlock_button": "Déverrouiller",
|
||||
"smime_passphrase_placeholder": "Phrase secrète S/MIME",
|
||||
"continue_draft": "Continuer le brouillon",
|
||||
"close_draft_title": "Enregistrer ou supprimer le brouillon ?",
|
||||
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "Dossiers",
|
||||
"keywords": "Mots-clés",
|
||||
"security": "Sécurité",
|
||||
"encryption": "Chiffrement",
|
||||
"files": "Fichiers",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "Apps de la barre latérale"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Général",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Supprimer définitivement",
|
||||
"warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Archiver dans",
|
||||
"description": "Comment organiser les emails lors de l'archivage",
|
||||
"single": "Un seul dossier",
|
||||
"year": "Un dossier par année",
|
||||
"month": "Un dossier par mois",
|
||||
"reorganize": "Réorganiser l'archive existante",
|
||||
"reorganize_success": "{count, plural, =0 {Aucun email à réorganiser} =1 {1 email réorganisé} other {# emails réorganisés}}",
|
||||
"reorganize_error": "Échec de la réorganisation de l'archive"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Supprimer définitivement les indésirables",
|
||||
"description": "Supprimer définitivement les e-mails du dossier Indésirables au lieu de les déplacer vers la corbeille"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "Emails par page",
|
||||
"description": "Nombre d'emails à charger à la fois"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "Toujours afficher les e-mails en mode clair",
|
||||
"description": "Afficher le contenu des e-mails en mode clair même lorsque l'application est en mode sombre, évitant les problèmes de conversion"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Contenu externe",
|
||||
"description": "Comment gérer les images et le contenu externe",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Importer"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "Apps de la barre latérale",
|
||||
"description": "Gérer les applications et liens personnalisés dans la barre latérale",
|
||||
"keep_loaded": "Garder les apps chargées",
|
||||
"keep_loaded_description": "Garder les apps en ligne en arrière-plan lors du basculement pour éviter le rechargement",
|
||||
"manage_title": "Apps personnalisées",
|
||||
"manage_description": "Ajouter, modifier ou supprimer des apps personnalisées de la barre latérale"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "Calendrier",
|
||||
"calendar_uri": "URL du calendrier",
|
||||
"scheduling_uri": "URL de planification",
|
||||
"freebusy_uri": "URL de disponibilité"
|
||||
"freebusy_uri": "URL de disponibilité",
|
||||
"cert_issuer": "Émetteur",
|
||||
"cert_expires": "Expire le",
|
||||
"cert_expired": "Expiré",
|
||||
"cert_fingerprint": "Empreinte",
|
||||
"cert_algorithm": "Algorithme",
|
||||
"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"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nouveau contact",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vos certificats",
|
||||
"your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails",
|
||||
"recipient_certificates": "Certificats des destinataires",
|
||||
"recipient_certificates_desc": "Certificats publics pour chiffrer les e-mails destinés aux destinataires",
|
||||
"identity_bindings": "Liaisons identité-clé",
|
||||
"identity_bindings_desc": "Associez des certificats S/MIME à vos identités de messagerie",
|
||||
"defaults_title": "Valeurs par défaut",
|
||||
"defaults_desc": "Configurez le comportement par défaut de signature et de chiffrement",
|
||||
"import_pkcs12": "Importer PKCS#12 (.p12/.pfx)",
|
||||
"import_public_cert": "Importer un certificat",
|
||||
"no_certificates": "Aucun certificat importé pour le moment",
|
||||
"no_recipient_certs": "Aucun certificat destinataire",
|
||||
"expires": "Expire le",
|
||||
"expired": "Expiré",
|
||||
"bound_to": "Lié à",
|
||||
"no_key_bound": "Aucun",
|
||||
"lock": "Verrouiller la clé",
|
||||
"unlock": "Déverrouiller la clé",
|
||||
"details": "Voir les détails",
|
||||
"delete": "Supprimer",
|
||||
"encrypt_by_default": "Chiffrer par défaut",
|
||||
"encrypt_by_default_desc": "Chiffrer automatiquement les e-mails lorsque tous les destinataires ont un certificat",
|
||||
"remember_unlocked": "Mémoriser les clés déverrouillées",
|
||||
"remember_unlocked_desc": "Conserver les clés déverrouillées pendant cette session du navigateur",
|
||||
"sign_default_for": "Signer par défaut pour",
|
||||
"enter_p12_passphrase": "Entrer la phrase secrète PKCS#12",
|
||||
"p12_passphrase_desc": "Entrez la phrase secrète qui protège ce fichier de certificat",
|
||||
"enter_storage_passphrase": "Définir la phrase secrète de stockage",
|
||||
"storage_passphrase_desc": "Choisissez une phrase secrète pour protéger cette clé dans votre navigateur",
|
||||
"next": "Suivant",
|
||||
"import": "Importer",
|
||||
"unlock_key": "Déverrouiller la clé",
|
||||
"unlock_key_desc": "Entrez la phrase secrète de stockage pour déverrouiller cette clé pour la signature ou le déchiffrement",
|
||||
"passphrase_placeholder": "Entrez la phrase secrète",
|
||||
"confirm_passphrase_placeholder": "Confirmez la phrase secrète",
|
||||
"passphrase_mismatch": "Les phrases secrètes ne correspondent pas",
|
||||
"cancel": "Annuler",
|
||||
"processing": "Traitement…",
|
||||
"close": "Fermer",
|
||||
"certificate_details": "Détails du certificat",
|
||||
"cert_subject": "Sujet",
|
||||
"cert_issuer": "Émetteur",
|
||||
"cert_email": "E-mail",
|
||||
"cert_serial": "Numéro de série",
|
||||
"cert_validity": "Validité",
|
||||
"cert_fingerprint": "Empreinte (SHA-256)",
|
||||
"cert_algorithm": "Algorithme",
|
||||
"cert_capabilities": "Capacités",
|
||||
"cert_source": "Source",
|
||||
"cert_expired": "Ce certificat a expiré",
|
||||
"cert_not_yet_valid": "Ce certificat n'est pas encore valide",
|
||||
"cap_sign": "Signature",
|
||||
"cap_encrypt": "Chiffrement",
|
||||
"cap_none": "Aucune",
|
||||
"show_passphrase": "Afficher la phrase secrète",
|
||||
"hide_passphrase": "Masquer la phrase secrète",
|
||||
"sign_toggle": "Signer",
|
||||
"encrypt_toggle": "Chiffrer",
|
||||
"missing_recipient_certs": "Certificats manquants pour : {emails}",
|
||||
"missing_sender_cert": "Aucun certificat n'est lié à cette identité",
|
||||
"status_encrypted_ok": "Ce message a été chiffré",
|
||||
"status_encrypted_no_key": "Ce message est chiffré, mais aucune clé correspondante n'a été trouvée",
|
||||
"status_encrypted_failed": "Impossible de déchiffrer ce message",
|
||||
"status_signed_valid": "Signature vérifiée",
|
||||
"status_signed_invalid": "Échec de la vérification de la signature",
|
||||
"status_signed_expired_cert": "Signé avec un certificat expiré",
|
||||
"status_signed_mismatch": "La signature est valide, mais le signataire ne correspond pas à l'expéditeur",
|
||||
"status_unsupported": "Format S/MIME non pris en charge",
|
||||
"auto_import_signer_certs": "Importer automatiquement les certificats des signataires",
|
||||
"auto_import_signer_certs_desc": "Enregistrer automatiquement les certificats des e-mails signés vérifiés pour un futur chiffrement",
|
||||
"export": "Exporter",
|
||||
"enter_export_passphrase": "Définir la phrase secrète d'export",
|
||||
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
|
||||
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
|
||||
"incorrect_passphrase": "Phrase secrète incorrecte"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Risponditore automatico attivo",
|
||||
"tags": "Etichette",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Navigazione"
|
||||
"nav_label": "Navigazione",
|
||||
"add_app": "App"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "App della barra laterale",
|
||||
"add_new": "Aggiungi app",
|
||||
"edit_app": "Modifica app",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "La mia app",
|
||||
"name_required": "Il nome è obbligatorio",
|
||||
"url_label": "URL",
|
||||
"url_required": "L'URL è obbligatorio",
|
||||
"url_invalid": "Inserisci un URL http o https valido",
|
||||
"icon_label": "Icona",
|
||||
"icon_required": "L'icona è obbligatoria",
|
||||
"open_mode_label": "Modalità di apertura",
|
||||
"open_new_tab": "Nuova scheda",
|
||||
"open_inline": "Integrato",
|
||||
"cancel": "Annulla",
|
||||
"add": "Aggiungi",
|
||||
"update": "Aggiorna",
|
||||
"delete": "Elimina",
|
||||
"delete_confirm_title": "Elimina app",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare \"{name}\"?",
|
||||
"no_apps": "Nessuna app aggiunta",
|
||||
"no_apps_hint": "Aggiungi app e link personalizzati alla barra laterale",
|
||||
"search_icons": "Cerca icone...",
|
||||
"show_popular": "Popolari",
|
||||
"show_all": "Tutte",
|
||||
"no_icons_found": "Nessuna icona trovata",
|
||||
"inline_badge": "Integrato",
|
||||
"tab_badge": "Scheda"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Nessun messaggio trovato",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "Annulla caricamento",
|
||||
"upload_failed": "Caricamento di {filename} non riuscito",
|
||||
"send_failed": "Invio dell'e-mail non riuscito",
|
||||
"smime_sign_on": "Firma S/MIME attivata",
|
||||
"smime_sign_off": "Firma S/MIME disattivata",
|
||||
"smime_encrypt_on": "Cifratura S/MIME attivata",
|
||||
"smime_encrypt_off": "Cifratura S/MIME disattivata",
|
||||
"smime_encrypt_unavailable": "La cifratura S/MIME non è disponibile: {reason}",
|
||||
"smime_unlock_title": "Sblocca chiave S/MIME",
|
||||
"smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.",
|
||||
"smime_unlock_button": "Sblocca",
|
||||
"smime_passphrase_placeholder": "Passphrase S/MIME",
|
||||
"continue_draft": "Continua bozza",
|
||||
"close_draft_title": "Salvare o eliminare la bozza?",
|
||||
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "Cartelle",
|
||||
"keywords": "Parole chiave",
|
||||
"security": "Sicurezza",
|
||||
"encryption": "Cifratura",
|
||||
"files": "File",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "App nella barra laterale"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Generale",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Elimina definitivamente",
|
||||
"warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Archivia in",
|
||||
"description": "Come organizzare le email durante l'archiviazione",
|
||||
"single": "Una singola cartella",
|
||||
"year": "Una cartella per anno",
|
||||
"month": "Una cartella per mese",
|
||||
"reorganize": "Riorganizza archivio esistente",
|
||||
"reorganize_success": "{count, plural, =0 {Nessuna email da riorganizzare} =1 {1 email riorganizzata} other {# email riorganizzate}}",
|
||||
"reorganize_error": "Impossibile riorganizzare l'archivio"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Elimina spam definitivamente",
|
||||
"description": "Elimina definitivamente i messaggi dalla cartella Spam invece di spostarli nel cestino"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "Messaggi per pagina",
|
||||
"description": "Numero di messaggi da caricare alla volta"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "Mostra sempre le email in modalità chiara",
|
||||
"description": "Visualizza il contenuto delle email in modalità chiara anche quando l'app è in modalità scura, evitando problemi di conversione"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Contenuti esterni",
|
||||
"description": "Come gestire immagini e contenuti esterni",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Importa"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "App nella barra laterale",
|
||||
"description": "Gestisci app e link personalizzati nella barra laterale",
|
||||
"keep_loaded": "Mantieni app caricate",
|
||||
"keep_loaded_description": "Mantieni le app inline in esecuzione in background durante il passaggio per evitare il ricaricamento",
|
||||
"manage_title": "App personalizzate",
|
||||
"manage_description": "Aggiungi, modifica o rimuovi app personalizzate dalla barra laterale"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "Calendario",
|
||||
"calendar_uri": "URL del calendario",
|
||||
"scheduling_uri": "URL di pianificazione",
|
||||
"freebusy_uri": "URL di disponibilità"
|
||||
"freebusy_uri": "URL di disponibilità",
|
||||
"cert_issuer": "Emittente",
|
||||
"cert_expires": "Scade",
|
||||
"cert_expired": "Scaduto",
|
||||
"cert_fingerprint": "Impronta digitale",
|
||||
"cert_algorithm": "Algoritmo",
|
||||
"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"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuovo contatto",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "I tuoi certificati",
|
||||
"your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email",
|
||||
"recipient_certificates": "Certificati dei destinatari",
|
||||
"recipient_certificates_desc": "Certificati pubblici per cifrare le email ai destinatari",
|
||||
"identity_bindings": "Associazioni identità-chiave",
|
||||
"identity_bindings_desc": "Associa i certificati S/MIME alle tue identità email",
|
||||
"defaults_title": "Predefiniti",
|
||||
"defaults_desc": "Configura il comportamento predefinito di firma e cifratura",
|
||||
"import_pkcs12": "Importa PKCS#12 (.p12/.pfx)",
|
||||
"import_public_cert": "Importa certificato",
|
||||
"no_certificates": "Nessun certificato importato",
|
||||
"no_recipient_certs": "Nessun certificato destinatario",
|
||||
"expires": "Scade",
|
||||
"expired": "Scaduto",
|
||||
"bound_to": "Associato a",
|
||||
"no_key_bound": "Nessuno",
|
||||
"lock": "Blocca chiave",
|
||||
"unlock": "Sblocca chiave",
|
||||
"details": "Visualizza dettagli",
|
||||
"delete": "Elimina",
|
||||
"encrypt_by_default": "Cifra per impostazione predefinita",
|
||||
"encrypt_by_default_desc": "Cifra automaticamente le email quando tutti i destinatari hanno un certificato",
|
||||
"remember_unlocked": "Ricorda le chiavi sbloccate",
|
||||
"remember_unlocked_desc": "Mantieni le chiavi sbloccate per la durata di questa sessione del browser",
|
||||
"sign_default_for": "Firma per impostazione predefinita per",
|
||||
"enter_p12_passphrase": "Inserisci la passphrase PKCS#12",
|
||||
"p12_passphrase_desc": "Inserisci la passphrase che protegge questo file di certificato",
|
||||
"enter_storage_passphrase": "Imposta la passphrase di archiviazione",
|
||||
"storage_passphrase_desc": "Scegli una passphrase per proteggere questa chiave nel browser",
|
||||
"next": "Avanti",
|
||||
"import": "Importa",
|
||||
"unlock_key": "Sblocca chiave",
|
||||
"unlock_key_desc": "Inserisci la passphrase di archiviazione per sbloccare questa chiave per la firma o la decrittazione",
|
||||
"passphrase_placeholder": "Inserisci la passphrase",
|
||||
"confirm_passphrase_placeholder": "Conferma la passphrase",
|
||||
"passphrase_mismatch": "Le passphrase non corrispondono",
|
||||
"cancel": "Annulla",
|
||||
"processing": "Elaborazione…",
|
||||
"close": "Chiudi",
|
||||
"certificate_details": "Dettagli del certificato",
|
||||
"cert_subject": "Oggetto",
|
||||
"cert_issuer": "Emittente",
|
||||
"cert_email": "Email",
|
||||
"cert_serial": "Numero di serie",
|
||||
"cert_validity": "Validità",
|
||||
"cert_fingerprint": "Impronta digitale (SHA-256)",
|
||||
"cert_algorithm": "Algoritmo",
|
||||
"cert_capabilities": "Capacità",
|
||||
"cert_source": "Origine",
|
||||
"cert_expired": "Questo certificato è scaduto",
|
||||
"cert_not_yet_valid": "Questo certificato non è ancora valido",
|
||||
"cap_sign": "Firma",
|
||||
"cap_encrypt": "Cifratura",
|
||||
"cap_none": "Nessuna",
|
||||
"show_passphrase": "Mostra passphrase",
|
||||
"hide_passphrase": "Nascondi passphrase",
|
||||
"sign_toggle": "Firma",
|
||||
"encrypt_toggle": "Cifra",
|
||||
"missing_recipient_certs": "Certificati mancanti per: {emails}",
|
||||
"missing_sender_cert": "Nessun certificato associato a questa identità",
|
||||
"status_encrypted_ok": "Questo messaggio è stato cifrato",
|
||||
"status_encrypted_no_key": "Questo messaggio è cifrato, ma non è stata trovata una chiave corrispondente",
|
||||
"status_encrypted_failed": "Impossibile decrittare questo messaggio",
|
||||
"status_signed_valid": "Firma verificata",
|
||||
"status_signed_invalid": "Verifica della firma non riuscita",
|
||||
"status_signed_expired_cert": "Firmato con un certificato scaduto",
|
||||
"status_signed_mismatch": "La firma è valida, ma il firmatario non corrisponde al mittente",
|
||||
"status_unsupported": "Formato S/MIME non supportato",
|
||||
"auto_import_signer_certs": "Importa automaticamente i certificati dei firmatari",
|
||||
"auto_import_signer_certs_desc": "Salva automaticamente i certificati delle email firmate verificate per future cifrature",
|
||||
"export": "Esporta",
|
||||
"enter_export_passphrase": "Imposta la passphrase di esportazione",
|
||||
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
|
||||
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
|
||||
"incorrect_passphrase": "Passphrase errata"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "不在応答が有効です",
|
||||
"tags": "タグ",
|
||||
"mail": "メール",
|
||||
"nav_label": "ナビゲーション"
|
||||
"nav_label": "ナビゲーション",
|
||||
"add_app": "アプリ"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "サイドバーアプリ",
|
||||
"add_new": "アプリを追加",
|
||||
"edit_app": "アプリを編集",
|
||||
"name_label": "名前",
|
||||
"name_placeholder": "マイアプリ",
|
||||
"name_required": "名前は必須です",
|
||||
"url_label": "URL",
|
||||
"url_required": "URLは必須です",
|
||||
"url_invalid": "有効なhttpまたはhttps URLを入力してください",
|
||||
"icon_label": "アイコン",
|
||||
"icon_required": "アイコンは必須です",
|
||||
"open_mode_label": "開き方",
|
||||
"open_new_tab": "新しいタブ",
|
||||
"open_inline": "埋め込み",
|
||||
"cancel": "キャンセル",
|
||||
"add": "追加",
|
||||
"update": "更新",
|
||||
"delete": "削除",
|
||||
"delete_confirm_title": "アプリを削除",
|
||||
"delete_confirm": "「{name}」を削除してもよろしいですか?",
|
||||
"no_apps": "アプリが追加されていません",
|
||||
"no_apps_hint": "カスタムアプリやリンクをサイドバーに追加できます",
|
||||
"search_icons": "アイコンを検索...",
|
||||
"show_popular": "人気",
|
||||
"show_all": "すべて",
|
||||
"no_icons_found": "アイコンが見つかりません",
|
||||
"inline_badge": "埋め込み",
|
||||
"tab_badge": "タブ"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "メッセージが見つかりません",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "アップロードをキャンセル",
|
||||
"upload_failed": "{filename} のアップロードに失敗しました",
|
||||
"send_failed": "メールの送信に失敗しました",
|
||||
"smime_sign_on": "S/MIME 署名を有効化しました",
|
||||
"smime_sign_off": "S/MIME 署名を無効化しました",
|
||||
"smime_encrypt_on": "S/MIME 暗号化を有効化しました",
|
||||
"smime_encrypt_off": "S/MIME 暗号化を無効化しました",
|
||||
"smime_encrypt_unavailable": "S/MIME 暗号化は利用できません: {reason}",
|
||||
"smime_unlock_title": "S/MIME 鍵のロックを解除",
|
||||
"smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。",
|
||||
"smime_unlock_button": "ロック解除",
|
||||
"smime_passphrase_placeholder": "S/MIME パスフレーズ",
|
||||
"continue_draft": "下書きを続ける",
|
||||
"close_draft_title": "下書きを保存または破棄しますか?",
|
||||
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "フォルダー",
|
||||
"keywords": "キーワード",
|
||||
"security": "セキュリティ",
|
||||
"encryption": "暗号化",
|
||||
"files": "ファイル",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "サイドバーアプリ"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "一般",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "完全に削除",
|
||||
"warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。"
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "アーカイブ先",
|
||||
"description": "アーカイブ時のメール整理方法",
|
||||
"single": "単一フォルダ",
|
||||
"year": "年ごとのフォルダ",
|
||||
"month": "月ごとのフォルダ",
|
||||
"reorganize": "既存アーカイブを再整理",
|
||||
"reorganize_success": "{count, plural, =0 {再整理するメールはありません} other {#通のメールを再整理しました}}",
|
||||
"reorganize_error": "アーカイブの再整理に失敗しました"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "迷惑メールを完全に削除",
|
||||
"description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "ページごとのメール数",
|
||||
"description": "一度に読み込むメールの数"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "メールを常にライトモードで表示",
|
||||
"description": "アプリがダークモードの場合でもメールをライトモードで表示し、ダークモード変換の問題を回避します"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "外部コンテンツ",
|
||||
"description": "画像と外部コンテンツの処理方法",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "インポート"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "サイドバーアプリ",
|
||||
"description": "サイドバーのカスタムアプリとリンクを管理",
|
||||
"keep_loaded": "アプリを読み込んだまま保持",
|
||||
"keep_loaded_description": "切り替え時にインラインアプリをバックグラウンドで実行し続けて再読み込みを回避",
|
||||
"manage_title": "カスタムアプリ",
|
||||
"manage_description": "サイドバーのカスタムアプリを追加、編集、削除"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "カレンダー",
|
||||
"calendar_uri": "カレンダーURL",
|
||||
"scheduling_uri": "スケジュールURL",
|
||||
"freebusy_uri": "空き状況URL"
|
||||
"freebusy_uri": "空き状況URL",
|
||||
"cert_issuer": "発行者",
|
||||
"cert_expires": "有効期限",
|
||||
"cert_expired": "期限切れ",
|
||||
"cert_fingerprint": "フィンガープリント",
|
||||
"cert_algorithm": "アルゴリズム",
|
||||
"import_to_smime": "S/MIME にインポート",
|
||||
"cert_already_imported": "証明書はすでにインポートされています",
|
||||
"cert_imported": "証明書をインポートしました",
|
||||
"cert_import_failed": "証明書のインポートに失敗しました"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新しい連絡先",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "あなたの証明書",
|
||||
"your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します",
|
||||
"recipient_certificates": "受信者証明書",
|
||||
"recipient_certificates_desc": "受信者へのメールを暗号化するための公開証明書",
|
||||
"identity_bindings": "ID と鍵の関連付け",
|
||||
"identity_bindings_desc": "S/MIME 証明書をメール ID に関連付けます",
|
||||
"defaults_title": "既定値",
|
||||
"defaults_desc": "署名と暗号化の既定動作を設定します",
|
||||
"import_pkcs12": "PKCS#12 をインポート (.p12/.pfx)",
|
||||
"import_public_cert": "証明書をインポート",
|
||||
"no_certificates": "まだ証明書はインポートされていません",
|
||||
"no_recipient_certs": "受信者証明書がありません",
|
||||
"expires": "有効期限",
|
||||
"expired": "期限切れ",
|
||||
"bound_to": "関連付け先",
|
||||
"no_key_bound": "なし",
|
||||
"lock": "鍵をロック",
|
||||
"unlock": "鍵をロック解除",
|
||||
"details": "詳細を表示",
|
||||
"delete": "削除",
|
||||
"encrypt_by_default": "既定で暗号化する",
|
||||
"encrypt_by_default_desc": "すべての受信者が証明書を持っている場合に自動でメールを暗号化します",
|
||||
"remember_unlocked": "ロック解除した鍵を記憶する",
|
||||
"remember_unlocked_desc": "このブラウザーセッションの間は鍵をロック解除したままにします",
|
||||
"sign_default_for": "既定で署名する対象",
|
||||
"enter_p12_passphrase": "PKCS#12 パスフレーズを入力",
|
||||
"p12_passphrase_desc": "この証明書ファイルを保護しているパスフレーズを入力してください",
|
||||
"enter_storage_passphrase": "保存用パスフレーズを設定",
|
||||
"storage_passphrase_desc": "ブラウザー内でこの鍵を保護するためのパスフレーズを選択してください",
|
||||
"next": "次へ",
|
||||
"import": "インポート",
|
||||
"unlock_key": "鍵をロック解除",
|
||||
"unlock_key_desc": "署名または復号に使うため、この鍵の保存用パスフレーズを入力してください",
|
||||
"passphrase_placeholder": "パスフレーズを入力",
|
||||
"confirm_passphrase_placeholder": "パスフレーズを確認",
|
||||
"passphrase_mismatch": "パスフレーズが一致しません",
|
||||
"cancel": "キャンセル",
|
||||
"processing": "処理中…",
|
||||
"close": "閉じる",
|
||||
"certificate_details": "証明書の詳細",
|
||||
"cert_subject": "件名",
|
||||
"cert_issuer": "発行者",
|
||||
"cert_email": "メールアドレス",
|
||||
"cert_serial": "シリアル番号",
|
||||
"cert_validity": "有効期間",
|
||||
"cert_fingerprint": "フィンガープリント (SHA-256)",
|
||||
"cert_algorithm": "アルゴリズム",
|
||||
"cert_capabilities": "機能",
|
||||
"cert_source": "ソース",
|
||||
"cert_expired": "この証明書は期限切れです",
|
||||
"cert_not_yet_valid": "この証明書はまだ有効ではありません",
|
||||
"cap_sign": "署名",
|
||||
"cap_encrypt": "暗号化",
|
||||
"cap_none": "なし",
|
||||
"show_passphrase": "パスフレーズを表示",
|
||||
"hide_passphrase": "パスフレーズを隠す",
|
||||
"sign_toggle": "署名",
|
||||
"encrypt_toggle": "暗号化",
|
||||
"missing_recipient_certs": "次の宛先の証明書がありません: {emails}",
|
||||
"missing_sender_cert": "この ID に関連付けられた証明書がありません",
|
||||
"status_encrypted_ok": "このメッセージは暗号化されています",
|
||||
"status_encrypted_no_key": "このメッセージは暗号化されていますが、一致する鍵が見つかりませんでした",
|
||||
"status_encrypted_failed": "このメッセージを復号できませんでした",
|
||||
"status_signed_valid": "署名を確認しました",
|
||||
"status_signed_invalid": "署名の検証に失敗しました",
|
||||
"status_signed_expired_cert": "期限切れの証明書で署名されています",
|
||||
"status_signed_mismatch": "署名は有効ですが、署名者が送信者と一致しません",
|
||||
"status_unsupported": "未対応の S/MIME 形式です",
|
||||
"auto_import_signer_certs": "署名者証明書を自動インポート",
|
||||
"auto_import_signer_certs_desc": "検証済み署名メールの証明書を今後の暗号化用に自動保存します",
|
||||
"export": "エクスポート",
|
||||
"enter_export_passphrase": "エクスポート用パスフレーズを設定",
|
||||
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
|
||||
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
|
||||
"incorrect_passphrase": "パスフレーズが正しくありません"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Afwezigheidsmelder is actief",
|
||||
"tags": "Labels",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie"
|
||||
"nav_label": "Navigatie",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Zijbalk-apps",
|
||||
"add_new": "App toevoegen",
|
||||
"edit_app": "App bewerken",
|
||||
"name_label": "Naam",
|
||||
"name_placeholder": "Mijn app",
|
||||
"name_required": "Naam is vereist",
|
||||
"url_label": "URL",
|
||||
"url_required": "URL is vereist",
|
||||
"url_invalid": "Voer een geldige http- of https-URL in",
|
||||
"icon_label": "Pictogram",
|
||||
"icon_required": "Pictogram is vereist",
|
||||
"open_mode_label": "Openingsmodus",
|
||||
"open_new_tab": "Nieuw tabblad",
|
||||
"open_inline": "Ingesloten",
|
||||
"cancel": "Annuleren",
|
||||
"add": "Toevoegen",
|
||||
"update": "Bijwerken",
|
||||
"delete": "Verwijderen",
|
||||
"delete_confirm_title": "App verwijderen",
|
||||
"delete_confirm": "Weet je zeker dat je \"{name}\" wilt verwijderen?",
|
||||
"no_apps": "Nog geen apps toegevoegd",
|
||||
"no_apps_hint": "Voeg aangepaste apps en links toe aan je zijbalk",
|
||||
"search_icons": "Pictogrammen zoeken...",
|
||||
"show_popular": "Populair",
|
||||
"show_all": "Alle",
|
||||
"no_icons_found": "Geen pictogrammen gevonden",
|
||||
"inline_badge": "Ingesloten",
|
||||
"tab_badge": "Tabblad"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Geen berichten gevonden",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "Upload annuleren",
|
||||
"upload_failed": "Uploaden van {filename} mislukt",
|
||||
"send_failed": "E-mail verzenden mislukt",
|
||||
"smime_sign_on": "S/MIME-ondertekening ingeschakeld",
|
||||
"smime_sign_off": "S/MIME-ondertekening uitgeschakeld",
|
||||
"smime_encrypt_on": "S/MIME-versleuteling ingeschakeld",
|
||||
"smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld",
|
||||
"smime_encrypt_unavailable": "S/MIME-versleuteling is niet beschikbaar: {reason}",
|
||||
"smime_unlock_title": "S/MIME-sleutel ontgrendelen",
|
||||
"smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.",
|
||||
"smime_unlock_button": "Ontgrendelen",
|
||||
"smime_passphrase_placeholder": "S/MIME-wachtwoordzin",
|
||||
"continue_draft": "Concept voortzetten",
|
||||
"close_draft_title": "Concept opslaan of verwijderen?",
|
||||
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "Mappen",
|
||||
"keywords": "Sleutelwoorden",
|
||||
"security": "Beveiliging",
|
||||
"encryption": "Versleuteling",
|
||||
"files": "Bestanden",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "Zijbalk-apps"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Algemeen",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Permanent verwijderen",
|
||||
"warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Archiveren in",
|
||||
"description": "Hoe e-mails bij archivering worden georganiseerd",
|
||||
"single": "Eén enkele map",
|
||||
"year": "Een map per jaar",
|
||||
"month": "Een map per maand",
|
||||
"reorganize": "Bestaand archief herorganiseren",
|
||||
"reorganize_success": "{count, plural, =0 {Geen e-mails om te herorganiseren} =1 {1 e-mail geherorganiseerd} other {# e-mails geherorganiseerd}}",
|
||||
"reorganize_error": "Archief herorganiseren mislukt"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Spam permanent verwijderen",
|
||||
"description": "E-mails uit de map Spam permanent verwijderen in plaats van naar de prullenbak te verplaatsen"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "E-mails per pagina",
|
||||
"description": "Aantal e-mails dat in één keer wordt geladen"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "E-mails altijd in lichte modus weergeven",
|
||||
"description": "E-mailinhoud in lichte modus weergeven, zelfs wanneer de app in donkere modus staat, om problemen met donkere modus-conversie te voorkomen"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Externe inhoud",
|
||||
"description": "Hoe afbeeldingen en externe inhoud moeten worden verwerkt",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Importeren"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "Zijbalk-apps",
|
||||
"description": "Aangepaste apps en links in de zijbalk beheren",
|
||||
"keep_loaded": "Apps geladen houden",
|
||||
"keep_loaded_description": "Inline-apps op de achtergrond laten draaien bij het wisselen om herladen te voorkomen",
|
||||
"manage_title": "Aangepaste apps",
|
||||
"manage_description": "Aangepaste apps toevoegen, bewerken of verwijderen uit de zijbalk"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "Kalender",
|
||||
"calendar_uri": "Kalender-URL",
|
||||
"scheduling_uri": "Planning-URL",
|
||||
"freebusy_uri": "Beschikbaarheid-URL"
|
||||
"freebusy_uri": "Beschikbaarheid-URL",
|
||||
"cert_issuer": "Uitgever",
|
||||
"cert_expires": "Verloopt",
|
||||
"cert_expired": "Verlopen",
|
||||
"cert_fingerprint": "Vingerafdruk",
|
||||
"cert_algorithm": "Algoritme",
|
||||
"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"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nieuw contact",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Uw certificaten",
|
||||
"your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails",
|
||||
"recipient_certificates": "Certificaten van ontvangers",
|
||||
"recipient_certificates_desc": "Openbare certificaten om e-mails naar ontvangers te versleutelen",
|
||||
"identity_bindings": "Koppelingen tussen identiteit en sleutel",
|
||||
"identity_bindings_desc": "Koppel S/MIME-certificaten aan uw e-mailidentiteiten",
|
||||
"defaults_title": "Standaardinstellingen",
|
||||
"defaults_desc": "Stel standaardgedrag voor ondertekenen en versleutelen in",
|
||||
"import_pkcs12": "PKCS#12 importeren (.p12/.pfx)",
|
||||
"import_public_cert": "Certificaat importeren",
|
||||
"no_certificates": "Nog geen certificaten geïmporteerd",
|
||||
"no_recipient_certs": "Geen ontvangerscertificaten",
|
||||
"expires": "Verloopt",
|
||||
"expired": "Verlopen",
|
||||
"bound_to": "Gekoppeld aan",
|
||||
"no_key_bound": "Geen",
|
||||
"lock": "Sleutel vergrendelen",
|
||||
"unlock": "Sleutel ontgrendelen",
|
||||
"details": "Details bekijken",
|
||||
"delete": "Verwijderen",
|
||||
"encrypt_by_default": "Standaard versleutelen",
|
||||
"encrypt_by_default_desc": "E-mails automatisch versleutelen wanneer alle ontvangers certificaten hebben",
|
||||
"remember_unlocked": "Ontgrendelde sleutels onthouden",
|
||||
"remember_unlocked_desc": "Sleutels ontgrendeld houden gedurende deze browsersessie",
|
||||
"sign_default_for": "Standaard ondertekenen voor",
|
||||
"enter_p12_passphrase": "PKCS#12-wachtwoordzin invoeren",
|
||||
"p12_passphrase_desc": "Voer de wachtwoordzin in die dit certificaatbestand beschermt",
|
||||
"enter_storage_passphrase": "Opslagwachtwoordzin instellen",
|
||||
"storage_passphrase_desc": "Kies een wachtwoordzin om deze sleutel in uw browser te beschermen",
|
||||
"next": "Volgende",
|
||||
"import": "Importeren",
|
||||
"unlock_key": "Sleutel ontgrendelen",
|
||||
"unlock_key_desc": "Voer de opslagwachtwoordzin in om deze sleutel te ontgrendelen voor ondertekenen of ontsleutelen",
|
||||
"passphrase_placeholder": "Voer wachtwoordzin in",
|
||||
"confirm_passphrase_placeholder": "Bevestig wachtwoordzin",
|
||||
"passphrase_mismatch": "Wachtwoordzinnen komen niet overeen",
|
||||
"cancel": "Annuleren",
|
||||
"processing": "Bezig met verwerken…",
|
||||
"close": "Sluiten",
|
||||
"certificate_details": "Certificaatdetails",
|
||||
"cert_subject": "Onderwerp",
|
||||
"cert_issuer": "Uitgever",
|
||||
"cert_email": "E-mail",
|
||||
"cert_serial": "Serienummer",
|
||||
"cert_validity": "Geldigheid",
|
||||
"cert_fingerprint": "Vingerafdruk (SHA-256)",
|
||||
"cert_algorithm": "Algoritme",
|
||||
"cert_capabilities": "Mogelijkheden",
|
||||
"cert_source": "Bron",
|
||||
"cert_expired": "Dit certificaat is verlopen",
|
||||
"cert_not_yet_valid": "Dit certificaat is nog niet geldig",
|
||||
"cap_sign": "Ondertekenen",
|
||||
"cap_encrypt": "Versleuteling",
|
||||
"cap_none": "Geen",
|
||||
"show_passphrase": "Wachtwoordzin tonen",
|
||||
"hide_passphrase": "Wachtwoordzin verbergen",
|
||||
"sign_toggle": "Ondertekenen",
|
||||
"encrypt_toggle": "Versleutelen",
|
||||
"missing_recipient_certs": "Ontbrekende certificaten voor: {emails}",
|
||||
"missing_sender_cert": "Er is geen certificaat gekoppeld aan deze identiteit",
|
||||
"status_encrypted_ok": "Dit bericht is versleuteld",
|
||||
"status_encrypted_no_key": "Dit bericht is versleuteld, maar er is geen passende sleutel gevonden",
|
||||
"status_encrypted_failed": "Dit bericht kon niet worden ontsleuteld",
|
||||
"status_signed_valid": "Handtekening geverifieerd",
|
||||
"status_signed_invalid": "Verificatie van de handtekening is mislukt",
|
||||
"status_signed_expired_cert": "Ondertekend met een verlopen certificaat",
|
||||
"status_signed_mismatch": "Handtekening is geldig, maar de ondertekenaar komt niet overeen met de afzender",
|
||||
"status_unsupported": "Niet-ondersteund S/MIME-formaat",
|
||||
"auto_import_signer_certs": "Ondertekenaarcertificaten automatisch importeren",
|
||||
"auto_import_signer_certs_desc": "Certificaten van geverifieerde ondertekende e-mails automatisch opslaan voor toekomstige versleuteling",
|
||||
"export": "Exporteren",
|
||||
"enter_export_passphrase": "Exportwachtwoordzin instellen",
|
||||
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
|
||||
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
|
||||
"incorrect_passphrase": "Onjuiste wachtwoordzin"
|
||||
}
|
||||
}
|
||||
|
||||
+153
-3
@@ -98,7 +98,38 @@
|
||||
"vacation_active": "Resposta automática ativa",
|
||||
"tags": "Etiquetas",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navegação"
|
||||
"nav_label": "Navegação",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Apps da barra lateral",
|
||||
"add_new": "Adicionar app",
|
||||
"edit_app": "Editar app",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "Meu app",
|
||||
"name_required": "Nome é obrigatório",
|
||||
"url_label": "URL",
|
||||
"url_required": "URL é obrigatório",
|
||||
"url_invalid": "Insira uma URL http ou https válida",
|
||||
"icon_label": "Ícone",
|
||||
"icon_required": "Ícone é obrigatório",
|
||||
"open_mode_label": "Modo de abertura",
|
||||
"open_new_tab": "Nova aba",
|
||||
"open_inline": "Integrado",
|
||||
"cancel": "Cancelar",
|
||||
"add": "Adicionar",
|
||||
"update": "Atualizar",
|
||||
"delete": "Excluir",
|
||||
"delete_confirm_title": "Excluir app",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir \"{name}\"?",
|
||||
"no_apps": "Nenhum app adicionado",
|
||||
"no_apps_hint": "Adicione apps e links personalizados à sua barra lateral",
|
||||
"search_icons": "Pesquisar ícones...",
|
||||
"show_popular": "Populares",
|
||||
"show_all": "Todos",
|
||||
"no_icons_found": "Nenhum ícone encontrado",
|
||||
"inline_badge": "Integrado",
|
||||
"tab_badge": "Aba"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Nenhuma mensagem encontrada",
|
||||
@@ -416,6 +447,15 @@
|
||||
"upload_cancel": "Cancelar envio",
|
||||
"upload_failed": "Falha ao enviar {filename}",
|
||||
"send_failed": "Falha ao enviar o e-mail",
|
||||
"smime_sign_on": "Assinatura S/MIME ativada",
|
||||
"smime_sign_off": "Assinatura S/MIME desativada",
|
||||
"smime_encrypt_on": "Criptografia S/MIME ativada",
|
||||
"smime_encrypt_off": "Criptografia S/MIME desativada",
|
||||
"smime_encrypt_unavailable": "A criptografia S/MIME não está disponível: {reason}",
|
||||
"smime_unlock_title": "Desbloquear chave S/MIME",
|
||||
"smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.",
|
||||
"smime_unlock_button": "Desbloquear",
|
||||
"smime_passphrase_placeholder": "Frase secreta S/MIME",
|
||||
"continue_draft": "Continuar rascunho",
|
||||
"close_draft_title": "Salvar ou descartar rascunho?",
|
||||
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
|
||||
@@ -552,8 +592,10 @@
|
||||
"folders": "Pastas",
|
||||
"keywords": "Palavras-chave",
|
||||
"security": "Segurança",
|
||||
"encryption": "Criptografia",
|
||||
"files": "Arquivos",
|
||||
"contacts": "Contacts"
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "Apps da barra lateral"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Geral",
|
||||
@@ -670,6 +712,16 @@
|
||||
"permanent": "Excluir Permanentemente",
|
||||
"warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível."
|
||||
},
|
||||
"archive_mode": {
|
||||
"label": "Arquivar em",
|
||||
"description": "Como organizar os e-mails ao arquivar",
|
||||
"single": "Uma única pasta",
|
||||
"year": "Uma pasta por ano",
|
||||
"month": "Uma pasta por mês",
|
||||
"reorganize": "Reorganizar arquivo existente",
|
||||
"reorganize_success": "{count, plural, =0 {Nenhum e-mail para reorganizar} =1 {1 e-mail reorganizado} other {# e-mails reorganizados}}",
|
||||
"reorganize_error": "Falha ao reorganizar o arquivo"
|
||||
},
|
||||
"permanently_delete_junk": {
|
||||
"label": "Excluir spam permanentemente",
|
||||
"description": "Excluir permanentemente e-mails da pasta Spam em vez de movê-los para a Lixeira"
|
||||
@@ -691,6 +743,10 @@
|
||||
"label": "E-mails Por Página",
|
||||
"description": "Número de e-mails a carregar de uma vez"
|
||||
},
|
||||
"always_light_mode": {
|
||||
"label": "Mostrar e-mails sempre em modo claro",
|
||||
"description": "Exibir o conteúdo dos e-mails em modo claro mesmo quando o aplicativo está em modo escuro, evitando problemas de conversão"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Conteúdo Externo",
|
||||
"description": "Como lidar com imagens e conteúdo externo",
|
||||
@@ -980,6 +1036,14 @@
|
||||
"button": "Importar"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"title": "Apps da barra lateral",
|
||||
"description": "Gerenciar aplicativos e links personalizados na barra lateral",
|
||||
"keep_loaded": "Manter apps carregados",
|
||||
"keep_loaded_description": "Manter apps inline em execução em segundo plano ao alternar entre eles para evitar recarregamento",
|
||||
"manage_title": "Apps personalizados",
|
||||
"manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
@@ -1435,7 +1499,16 @@
|
||||
"calendar": "Calendário",
|
||||
"calendar_uri": "URL do calendário",
|
||||
"scheduling_uri": "URL de agendamento",
|
||||
"freebusy_uri": "URL de disponibilidade"
|
||||
"freebusy_uri": "URL de disponibilidade",
|
||||
"cert_issuer": "Emissor",
|
||||
"cert_expires": "Expira em",
|
||||
"cert_expired": "Expirado",
|
||||
"cert_fingerprint": "Impressão digital",
|
||||
"cert_algorithm": "Algoritmo",
|
||||
"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"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Novo contato",
|
||||
@@ -1985,5 +2058,82 @@
|
||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||
"settings_folder_layout_inline": "Inline",
|
||||
"settings_folder_layout_sidebar": "Sidebar"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Seus certificados",
|
||||
"your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails",
|
||||
"recipient_certificates": "Certificados dos destinatários",
|
||||
"recipient_certificates_desc": "Certificados públicos para criptografar e-mails para destinatários",
|
||||
"identity_bindings": "Vínculos de identidade e chave",
|
||||
"identity_bindings_desc": "Associe certificados S/MIME às suas identidades de e-mail",
|
||||
"defaults_title": "Padrões",
|
||||
"defaults_desc": "Configure o comportamento padrão de assinatura e criptografia",
|
||||
"import_pkcs12": "Importar PKCS#12 (.p12/.pfx)",
|
||||
"import_public_cert": "Importar certificado",
|
||||
"no_certificates": "Nenhum certificado importado ainda",
|
||||
"no_recipient_certs": "Nenhum certificado de destinatário",
|
||||
"expires": "Expira em",
|
||||
"expired": "Expirado",
|
||||
"bound_to": "Vinculado a",
|
||||
"no_key_bound": "Nenhum",
|
||||
"lock": "Bloquear chave",
|
||||
"unlock": "Desbloquear chave",
|
||||
"details": "Ver detalhes",
|
||||
"delete": "Excluir",
|
||||
"encrypt_by_default": "Criptografar por padrão",
|
||||
"encrypt_by_default_desc": "Criptografar e-mails automaticamente quando todos os destinatários tiverem certificados",
|
||||
"remember_unlocked": "Lembrar chaves desbloqueadas",
|
||||
"remember_unlocked_desc": "Manter as chaves desbloqueadas durante esta sessão do navegador",
|
||||
"sign_default_for": "Assinar por padrão para",
|
||||
"enter_p12_passphrase": "Inserir a frase secreta do PKCS#12",
|
||||
"p12_passphrase_desc": "Insira a frase secreta que protege este arquivo de certificado",
|
||||
"enter_storage_passphrase": "Definir frase secreta de armazenamento",
|
||||
"storage_passphrase_desc": "Escolha uma frase secreta para proteger esta chave no navegador",
|
||||
"next": "Próximo",
|
||||
"import": "Importar",
|
||||
"unlock_key": "Desbloquear chave",
|
||||
"unlock_key_desc": "Insira a frase secreta de armazenamento para desbloquear esta chave para assinatura ou descriptografia",
|
||||
"passphrase_placeholder": "Insira a frase secreta",
|
||||
"confirm_passphrase_placeholder": "Confirme a frase secreta",
|
||||
"passphrase_mismatch": "As frases secretas não coincidem",
|
||||
"cancel": "Cancelar",
|
||||
"processing": "Processando…",
|
||||
"close": "Fechar",
|
||||
"certificate_details": "Detalhes do certificado",
|
||||
"cert_subject": "Assunto",
|
||||
"cert_issuer": "Emissor",
|
||||
"cert_email": "E-mail",
|
||||
"cert_serial": "Número de série",
|
||||
"cert_validity": "Validade",
|
||||
"cert_fingerprint": "Impressão digital (SHA-256)",
|
||||
"cert_algorithm": "Algoritmo",
|
||||
"cert_capabilities": "Recursos",
|
||||
"cert_source": "Origem",
|
||||
"cert_expired": "Este certificado expirou",
|
||||
"cert_not_yet_valid": "Este certificado ainda não é válido",
|
||||
"cap_sign": "Assinatura",
|
||||
"cap_encrypt": "Criptografia",
|
||||
"cap_none": "Nenhum",
|
||||
"show_passphrase": "Mostrar frase secreta",
|
||||
"hide_passphrase": "Ocultar frase secreta",
|
||||
"sign_toggle": "Assinar",
|
||||
"encrypt_toggle": "Criptografar",
|
||||
"missing_recipient_certs": "Certificados ausentes para: {emails}",
|
||||
"missing_sender_cert": "Nenhum certificado vinculado a esta identidade",
|
||||
"status_encrypted_ok": "Esta mensagem foi criptografada",
|
||||
"status_encrypted_no_key": "Esta mensagem está criptografada, mas nenhuma chave correspondente foi encontrada",
|
||||
"status_encrypted_failed": "Falha ao descriptografar esta mensagem",
|
||||
"status_signed_valid": "Assinatura verificada",
|
||||
"status_signed_invalid": "A verificação da assinatura falhou",
|
||||
"status_signed_expired_cert": "Assinada com um certificado expirado",
|
||||
"status_signed_mismatch": "A assinatura é válida, mas o signatário não corresponde ao remetente",
|
||||
"status_unsupported": "Formato S/MIME não suportado",
|
||||
"auto_import_signer_certs": "Importar automaticamente certificados de signatários",
|
||||
"auto_import_signer_certs_desc": "Salvar automaticamente certificados de e-mails assinados verificados para criptografia futura",
|
||||
"export": "Exportar",
|
||||
"enter_export_passphrase": "Definir frase secreta de exportação",
|
||||
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
|
||||
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
|
||||
"incorrect_passphrase": "Frase secreta incorreta"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+108
-2
@@ -1,21 +1,25 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"asn1js": "^3.0.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^16.1.5",
|
||||
"next-intl": "^4.5.8",
|
||||
"pkijs": "^3.3.3",
|
||||
"postal-mime": "^2.7.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -39,6 +43,7 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
@@ -2048,6 +2053,21 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
@@ -4055,6 +4075,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -4177,6 +4211,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bytestreamjs": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
|
||||
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -5238,6 +5281,16 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -7256,6 +7309,35 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pkijs": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
|
||||
"integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.4.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"bytestreamjs": "^2.0.1",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pkijs/node_modules/@noble/hashes": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
|
||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
@@ -7304,6 +7386,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -7400,6 +7488,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
|
||||
+8
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Matthieu MALVACHE <matthieu@root.cloud>",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -27,17 +27,22 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"test:translations": "vitest run lib/__tests__/translations.test.ts",
|
||||
"prepare": "husky",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"asn1js": "^3.0.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^16.1.5",
|
||||
"next-intl": "^4.5.8",
|
||||
"pkijs": "^3.3.3",
|
||||
"postal-mime": "^2.7.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -61,6 +66,7 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Debug script for TNEF parser — dumps raw attribute structure.
|
||||
*/
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
if (!inputPath) {
|
||||
console.error('Usage: npx tsx scripts/debug-tnef.ts <path-to-winmail.dat>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = new Uint8Array(readFileSync(resolve(inputPath)));
|
||||
|
||||
// TNEF attribute ID names
|
||||
const ATTR_NAMES: Record<number, string> = {
|
||||
0x00069003: 'attMAPIProps',
|
||||
0x0002800C: 'attBody',
|
||||
0x00069002: 'attAttachRenddata',
|
||||
0x0006800F: 'attAttachData',
|
||||
0x00018010: 'attAttachTitle',
|
||||
0x00069005: 'attAttachment (MAPI)',
|
||||
0x00028005: 'attSubject',
|
||||
0x00068007: 'attMessageClass',
|
||||
0x00078006: 'attDateSent',
|
||||
0x00078008: 'attDateModified',
|
||||
0x0006900B: 'attRecipTable',
|
||||
0x00069001: 'attOwner',
|
||||
0x00060001: 'attFrom',
|
||||
0x00078004: 'attDateStart',
|
||||
0x0001800A: 'attMessageID',
|
||||
0x00050008: 'attPriority',
|
||||
0x00040009: 'attAidOwner',
|
||||
0x00010004: 'attConversationID',
|
||||
0x0001800D: 'attParentID',
|
||||
0x00018011: 'attAttachCreateDate',
|
||||
0x00018012: 'attAttachModifyDate',
|
||||
0x00060002: 'attDateRecd',
|
||||
0x00060003: 'attAssignedTo',
|
||||
};
|
||||
|
||||
const MAPI_PROP_NAMES: Record<number, string> = {
|
||||
0x0037: 'PR_SUBJECT',
|
||||
0x1000: 'PR_BODY',
|
||||
0x1009: 'PR_RTF_COMPRESSED',
|
||||
0x1013: 'PR_BODY_HTML',
|
||||
0x1014: 'PR_BODY_CONTENT_ID',
|
||||
0x0E1F: 'PR_RTF_IN_SYNC',
|
||||
0x3701: 'PR_ATTACH_DATA_BIN',
|
||||
0x3702: 'PR_ATTACH_ENCODING',
|
||||
0x3703: 'PR_ATTACH_EXTENSION',
|
||||
0x3704: 'PR_ATTACH_FILENAME',
|
||||
0x3707: 'PR_ATTACH_LONG_FILENAME',
|
||||
0x370E: 'PR_ATTACH_MIME_TAG',
|
||||
0x3712: 'PR_ATTACH_CONTENT_ID',
|
||||
0x0FF9: 'PR_RECORD_KEY',
|
||||
0x0FFE: 'PR_OBJECT_TYPE',
|
||||
0x3001: 'PR_DISPLAY_NAME',
|
||||
0x3002: 'PR_ADDRTYPE',
|
||||
0x3003: 'PR_EMAIL_ADDRESS',
|
||||
};
|
||||
|
||||
const PROP_TYPE_NAMES: Record<number, string> = {
|
||||
0x0002: 'PT_SHORT',
|
||||
0x0003: 'PT_LONG',
|
||||
0x000B: 'PT_BOOLEAN',
|
||||
0x001E: 'PT_STRING8',
|
||||
0x001F: 'PT_UNICODE',
|
||||
0x0040: 'PT_SYSTIME',
|
||||
0x0048: 'PT_CLSID',
|
||||
0x0102: 'PT_BINARY',
|
||||
0x0014: 'PT_I8',
|
||||
};
|
||||
|
||||
function pad4(len: number): number {
|
||||
return (4 - (len % 4)) % 4;
|
||||
}
|
||||
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
let offset = 0;
|
||||
|
||||
function readU8() { return view.getUint8(offset++); }
|
||||
function readU16() { const v = view.getUint16(offset, true); offset += 2; return v; }
|
||||
function readU32() { const v = view.getUint32(offset, true); offset += 4; return v; }
|
||||
function readBytes(n: number) { const s = data.slice(offset, offset + n); offset += n; return s; }
|
||||
|
||||
const sig = readU32();
|
||||
console.log(`Signature: 0x${sig.toString(16)} (expected 0x223e9f78: ${sig === 0x223e9f78 ? 'OK' : 'MISMATCH'})`);
|
||||
const key = readU16();
|
||||
console.log(`Key: ${key}\n`);
|
||||
|
||||
let attrIndex = 0;
|
||||
while (offset + 11 <= data.byteLength) {
|
||||
const level = readU8();
|
||||
const attrId = readU32();
|
||||
const attrLen = readU32();
|
||||
|
||||
if (attrLen > data.byteLength - offset - 2) {
|
||||
console.log(`[${attrIndex}] TRUNCATED — level=${level} id=0x${attrId.toString(16)} len=${attrLen} (remaining=${data.byteLength - offset})`);
|
||||
break;
|
||||
}
|
||||
|
||||
const attrData = readBytes(attrLen);
|
||||
const checksum = readU16();
|
||||
|
||||
const levelStr = level === 1 ? 'MESSAGE' : level === 2 ? 'ATTACHMENT' : `LEVEL(${level})`;
|
||||
const attrName = ATTR_NAMES[attrId] || `0x${attrId.toString(16).padStart(8, '0')}`;
|
||||
|
||||
console.log(`[${attrIndex}] ${levelStr} | ${attrName} | ${attrLen} bytes | checksum=0x${checksum.toString(16)}`);
|
||||
|
||||
// Dump MAPI props if this is a MAPI attr
|
||||
if (attrId === 0x00069003 || attrId === 0x00069005) {
|
||||
const propView = new DataView(attrData.buffer, attrData.byteOffset, attrData.byteLength);
|
||||
let pOff = 0;
|
||||
if (attrData.byteLength >= 4) {
|
||||
const count = propView.getUint32(pOff, true); pOff += 4;
|
||||
console.log(` MAPI props count: ${count}`);
|
||||
|
||||
for (let i = 0; i < count && pOff + 4 <= attrData.byteLength; i++) {
|
||||
const propType = propView.getUint16(pOff, true); pOff += 2;
|
||||
const propId = propView.getUint16(pOff, true); pOff += 2;
|
||||
|
||||
const baseType = propType & 0x0FFF;
|
||||
const isMulti = (propType & 0x1000) !== 0;
|
||||
const propName = MAPI_PROP_NAMES[propId] || `0x${propId.toString(16).padStart(4, '0')}`;
|
||||
const typeName = PROP_TYPE_NAMES[baseType] || `0x${baseType.toString(16).padStart(4, '0')}`;
|
||||
|
||||
// Named props
|
||||
if (propId >= 0x8000) {
|
||||
if (pOff + 20 > attrData.byteLength) { console.log(` [${i}] ${propName} (${typeName}) — TRUNCATED (named prop)`); break; }
|
||||
pOff += 16; // GUID
|
||||
const kind = propView.getUint32(pOff, true); pOff += 4;
|
||||
if (kind === 0) {
|
||||
if (pOff + 4 > attrData.byteLength) break;
|
||||
pOff += 4;
|
||||
} else {
|
||||
if (pOff + 4 > attrData.byteLength) break;
|
||||
const nl = propView.getUint32(pOff, true); pOff += 4;
|
||||
if (pOff + nl > attrData.byteLength) break;
|
||||
pOff += nl + pad4(nl);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMulti) {
|
||||
if (pOff + 4 > attrData.byteLength) break;
|
||||
const vc = propView.getUint32(pOff, true); pOff += 4;
|
||||
console.log(` [${i}] ${propName} (${typeName} MV x${vc})`);
|
||||
for (let j = 0; j < vc; j++) {
|
||||
// skip values
|
||||
if (baseType === 0x001E || baseType === 0x001F || baseType === 0x0102) {
|
||||
if (pOff + 4 > attrData.byteLength) break;
|
||||
const vl = propView.getUint32(pOff, true); pOff += 4;
|
||||
pOff += vl + pad4(vl);
|
||||
} else if (baseType === 0x0040 || baseType === 0x0014) {
|
||||
pOff += 8;
|
||||
} else if (baseType === 0x0048) {
|
||||
pOff += 16;
|
||||
} else if (baseType === 0x0002) {
|
||||
pOff += 4;
|
||||
} else {
|
||||
pOff += 4;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let valuePreview = '';
|
||||
const savedOff = pOff;
|
||||
|
||||
if (baseType === 0x0002) {
|
||||
if (pOff + 4 <= attrData.byteLength) {
|
||||
valuePreview = `value=${propView.getUint16(pOff, true)}`;
|
||||
pOff += 4; // padded
|
||||
}
|
||||
} else if (baseType === 0x0003 || baseType === 0x000B) {
|
||||
if (pOff + 4 <= attrData.byteLength) {
|
||||
valuePreview = `value=${propView.getUint32(pOff, true)}`;
|
||||
pOff += 4;
|
||||
}
|
||||
} else if (baseType === 0x0014 || baseType === 0x0040) {
|
||||
pOff += 8;
|
||||
valuePreview = '(8 bytes)';
|
||||
} else if (baseType === 0x0048) {
|
||||
pOff += 16;
|
||||
valuePreview = '(GUID)';
|
||||
} else if (baseType === 0x001E || baseType === 0x001F || baseType === 0x0102) {
|
||||
if (pOff + 4 <= attrData.byteLength) {
|
||||
const vl = propView.getUint32(pOff, true); pOff += 4;
|
||||
if (pOff + vl <= attrData.byteLength) {
|
||||
const raw = attrData.slice(pOff, pOff + vl);
|
||||
if (baseType === 0x001F) {
|
||||
try { valuePreview = `"${new TextDecoder('utf-16le').decode(raw).slice(0, 120)}"`; } catch { valuePreview = `(${vl} bytes)`; }
|
||||
} else if (baseType === 0x001E) {
|
||||
try { valuePreview = `"${new TextDecoder('utf-8').decode(raw).slice(0, 120)}"`; } catch { valuePreview = `(${vl} bytes)`; }
|
||||
} else {
|
||||
valuePreview = `(${vl} bytes binary)`;
|
||||
if (propId === 0x1013) {
|
||||
try { valuePreview += ` preview="${new TextDecoder('utf-8').decode(raw).slice(0, 200)}"`; } catch { /* ignore decode errors */ }
|
||||
}
|
||||
}
|
||||
pOff += vl + pad4(vl);
|
||||
} else {
|
||||
valuePreview = `(${vl} bytes — exceeds data)`;
|
||||
pOff = savedOff + 4;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pOff + 4 <= attrData.byteLength) {
|
||||
pOff += 4;
|
||||
valuePreview = '(4 bytes fixed)';
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` [${i}] ${propName} (${typeName}) ${valuePreview}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview plain text body/attach title
|
||||
if (attrId === 0x0002800C || attrId === 0x00018010) {
|
||||
try {
|
||||
const preview = new TextDecoder('utf-8').decode(attrData.slice(0, Math.min(200, attrData.byteLength)));
|
||||
console.log(` Preview: "${preview}"`);
|
||||
} catch { /* ignore decode errors */ }
|
||||
}
|
||||
|
||||
attrIndex++;
|
||||
}
|
||||
|
||||
console.log(`\nTotal attributes: ${attrIndex}`);
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Generate a self-signed S/MIME test certificate (.p12) using pkijs.
|
||||
* Usage: npx tsx scripts/generate-test-cert.ts
|
||||
*/
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
pkijs.setEngine('gen', crypto, cryptoEngine);
|
||||
|
||||
function stringToAB(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) view[i] = str.charCodeAt(i);
|
||||
return buf;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] || 'test@example.com';
|
||||
const cn = email.split('@')[0];
|
||||
const p12Password = 'test';
|
||||
|
||||
console.log(`Generating S/MIME certificate for ${email}...`);
|
||||
|
||||
// Generate RSA key pair for signing
|
||||
const signKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// Build self-signed certificate
|
||||
const cert = new pkijs.Certificate();
|
||||
cert.version = 2;
|
||||
cert.serialNumber = new asn1js.Integer({ value: Date.now() });
|
||||
|
||||
// Issuer = Subject (self-signed)
|
||||
for (const name of [cert.issuer, cert.subject]) {
|
||||
name.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: cn }) }),
|
||||
);
|
||||
name.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({ type: '2.5.4.10', value: new asn1js.Utf8String({ value: 'Test Org' }) }),
|
||||
);
|
||||
}
|
||||
// Email in subject
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({ type: '1.2.840.113549.1.9.1', value: new asn1js.IA5String({ value: email }) }),
|
||||
);
|
||||
|
||||
// Validity: 1 year
|
||||
cert.notBefore.value = new Date();
|
||||
const notAfter = new Date();
|
||||
notAfter.setFullYear(notAfter.getFullYear() + 1);
|
||||
cert.notAfter.value = notAfter;
|
||||
|
||||
// Import public key and sign
|
||||
await cert.subjectPublicKeyInfo.importKey(signKeyPair.publicKey, cryptoEngine);
|
||||
await cert.sign(signKeyPair.privateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
// Export private key as PKCS#8
|
||||
const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', signKeyPair.privateKey);
|
||||
|
||||
// Build PKCS#12
|
||||
const passwordBuf = stringToAB(p12Password);
|
||||
|
||||
const keyBag = new pkijs.PKCS8ShroudedKeyBag({
|
||||
parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes),
|
||||
});
|
||||
|
||||
await keyBag.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
contentEncryptionAlgorithm: {
|
||||
name: 'AES-CBC',
|
||||
length: 256,
|
||||
} as Parameters<typeof keyBag.makeInternalValues>[0]['contentEncryptionAlgorithm'],
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
iterationCount: 100_000,
|
||||
});
|
||||
|
||||
const keyBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.2',
|
||||
bagValue: keyBag,
|
||||
bagAttributes: [
|
||||
new pkijs.Attribute({
|
||||
type: '1.2.840.113549.1.9.20', // friendlyName
|
||||
values: [new asn1js.BmpString({ value: cn })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const certBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3',
|
||||
bagValue: new pkijs.CertBag({ parsedValue: cert }),
|
||||
bagAttributes: [
|
||||
new pkijs.Attribute({
|
||||
type: '1.2.840.113549.1.9.20',
|
||||
values: [new asn1js.BmpString({ value: cn })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const authenticatedSafe = new pkijs.AuthenticatedSafe({
|
||||
parsedValue: {
|
||||
safeContents: [
|
||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [keyBagSafe] }) },
|
||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [certBagSafe] }) },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await authenticatedSafe.makeInternalValues({ safeContents: [{}, {}] });
|
||||
|
||||
const pfx = new pkijs.PFX({
|
||||
parsedValue: {
|
||||
integrityMode: 0,
|
||||
authenticatedSafe,
|
||||
},
|
||||
});
|
||||
|
||||
await pfx.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
iterations: 100_000,
|
||||
pbkdf2HashAlgorithm: 'SHA-256',
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
});
|
||||
|
||||
const p12Bytes = pfx.toSchema().toBER(false);
|
||||
|
||||
// Also export the public cert as PEM
|
||||
const certDer = cert.toSchema(true).toBER(false);
|
||||
const certB64 = Buffer.from(certDer).toString('base64');
|
||||
const certPem = `-----BEGIN CERTIFICATE-----\n${certB64.match(/.{1,64}/g)!.join('\n')}\n-----END CERTIFICATE-----\n`;
|
||||
|
||||
const slug = email.replace(/[@.]/g, '-');
|
||||
const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'local-data');
|
||||
const p12Path = join(outDir, `${slug}.p12`);
|
||||
const pemPath = join(outDir, `${slug}-cert.pem`);
|
||||
|
||||
writeFileSync(p12Path, Buffer.from(p12Bytes));
|
||||
writeFileSync(pemPath, certPem);
|
||||
|
||||
console.log(`\nFiles written:`);
|
||||
console.log(` ${p12Path}`);
|
||||
console.log(` ${pemPath}`);
|
||||
console.log(`\nCredentials:`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` CN: ${cn}`);
|
||||
console.log(` Password: ${p12Password}`);
|
||||
console.log(` Valid until: ${notAfter.toISOString().split('T')[0]}`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Test script for TNEF (winmail.dat) parser.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/test-tnef.ts <path-to-winmail.dat>
|
||||
*
|
||||
* Outputs:
|
||||
* - tnef-output.html (HTML body or formatted plain text)
|
||||
* - Any extracted attachments saved alongside
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
import { parseTnef } from '../lib/tnef';
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
if (!inputPath) {
|
||||
console.error('Usage: npx tsx scripts/test-tnef.ts <path-to-winmail.dat>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fullPath = resolve(inputPath);
|
||||
console.log(`Reading: ${fullPath}`);
|
||||
|
||||
const data = new Uint8Array(readFileSync(fullPath));
|
||||
console.log(`File size: ${data.byteLength} bytes`);
|
||||
|
||||
const result = parseTnef(data);
|
||||
|
||||
console.log(`\n=== TNEF Parse Results ===`);
|
||||
console.log(`Plain text body: ${result.body ? `${result.body.length} chars` : 'none'}`);
|
||||
console.log(`HTML body: ${result.htmlBody ? `${result.htmlBody.length} chars` : 'none'}`);
|
||||
console.log(`Attachments: ${result.attachments.length}`);
|
||||
|
||||
if (result.attachments.length > 0) {
|
||||
console.log(`\nAttachments:`);
|
||||
result.attachments.forEach((att, i) => {
|
||||
console.log(` [${i + 1}] ${att.name} (${att.mimeType}, ${att.data.byteLength} bytes)`);
|
||||
});
|
||||
}
|
||||
|
||||
// Build output HTML
|
||||
let htmlContent: string;
|
||||
|
||||
const attachmentsList = result.attachments.length > 0
|
||||
? `<h3>Extracted Attachments (${result.attachments.length})</h3>
|
||||
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;font-family:sans-serif;">
|
||||
<tr style="background:#f0f0f0;"><th>#</th><th>Name</th><th>MIME Type</th><th>Size</th></tr>
|
||||
${result.attachments.map((att, i) => `<tr><td>${i+1}</td><td>${att.name}</td><td>${att.mimeType}</td><td>${att.data.byteLength} bytes</td></tr>`).join('\n')}
|
||||
</table>`
|
||||
: '<p>No attachments found.</p>';
|
||||
|
||||
if (result.htmlBody) {
|
||||
htmlContent = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>TNEF Output</title></head>
|
||||
<body style="font-family:sans-serif;max-width:900px;margin:20px auto;">
|
||||
<h2 style="color:#333;border-bottom:2px solid #0078d4;padding-bottom:8px;">TNEF Parse Results</h2>
|
||||
<p><strong>Source:</strong> ${inputPath} (${data.byteLength} bytes)</p>
|
||||
${attachmentsList}
|
||||
<h3>HTML Body</h3>
|
||||
<div style="border:1px solid #ccc;padding:16px;border-radius:4px;background:#fff;">
|
||||
${result.htmlBody}
|
||||
</div>
|
||||
</body></html>`;
|
||||
} else if (result.body) {
|
||||
const escaped = result.body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
htmlContent = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>TNEF Output</title></head>
|
||||
<body style="font-family:sans-serif;max-width:900px;margin:20px auto;">
|
||||
<h2 style="color:#333;border-bottom:2px solid #0078d4;padding-bottom:8px;">TNEF Parse Results</h2>
|
||||
<p><strong>Source:</strong> ${inputPath} (${data.byteLength} bytes)</p>
|
||||
${attachmentsList}
|
||||
<h3>Plain Text Body</h3>
|
||||
<pre style="font-family:Consolas,monospace;white-space:pre-wrap;line-height:1.6;border:1px solid #ccc;padding:16px;border-radius:4px;background:#fff;">${escaped}</pre>
|
||||
</body></html>`;
|
||||
} else {
|
||||
htmlContent = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>TNEF Output</title></head>
|
||||
<body style="font-family:sans-serif;max-width:900px;margin:20px auto;">
|
||||
<h2 style="color:#333;border-bottom:2px solid #0078d4;padding-bottom:8px;">TNEF Parse Results</h2>
|
||||
<p><strong>Source:</strong> ${inputPath} (${data.byteLength} bytes)</p>
|
||||
<p style="color:#666;"><em>No body content found in this TNEF file. The email body is likely in the regular MIME text/plain part.</em></p>
|
||||
${attachmentsList}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
const outputHtml = resolve('tnef-output.html');
|
||||
writeFileSync(outputHtml, htmlContent, 'utf-8');
|
||||
console.log(`\nSaved HTML: ${outputHtml}`);
|
||||
|
||||
// Save extracted attachments
|
||||
result.attachments.forEach((att, i) => {
|
||||
const attPath = resolve(`tnef-attachment-${i + 1}-${att.name}`);
|
||||
writeFileSync(attPath, att.data);
|
||||
console.log(`Saved attachment: ${attPath}`);
|
||||
});
|
||||
|
||||
console.log('\nDone.');
|
||||
@@ -67,6 +67,9 @@ CFG_LOG_LEVEL="info"
|
||||
CFG_LOGIN_COMPANY_NAME=""
|
||||
CFG_LOGIN_LOGO_LIGHT_URL=""
|
||||
CFG_LOGIN_LOGO_DARK_URL=""
|
||||
CFG_FAVICON_URL=""
|
||||
CFG_APP_LOGO_LIGHT_URL=""
|
||||
CFG_APP_LOGO_DARK_URL=""
|
||||
CFG_LOGIN_IMPRINT_URL=""
|
||||
CFG_LOGIN_PRIVACY_POLICY_URL=""
|
||||
CFG_LOGIN_WEBSITE_URL=""
|
||||
@@ -761,20 +764,30 @@ screen_login_customization() {
|
||||
CURRENT_STEP=5
|
||||
draw_header
|
||||
|
||||
section_header 5 "Login Page Branding" \
|
||||
"Add your company name and legal links to the login page."
|
||||
section_header 5 "Branding" \
|
||||
"Customize logos, favicon, and login page for your domain identity."
|
||||
|
||||
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
|
||||
echo ""
|
||||
|
||||
echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
|
||||
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
|
||||
echo ""
|
||||
echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}"
|
||||
prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
|
||||
echo ""
|
||||
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
|
||||
echo ""
|
||||
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
|
||||
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}Custom logo URLs for the login page (PNG, SVG, etc.)${RESET}"
|
||||
echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}"
|
||||
prompt_value "Logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
|
||||
prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
|
||||
echo ""
|
||||
prompt_value "Logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
|
||||
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
|
||||
echo ""
|
||||
prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
|
||||
echo ""
|
||||
@@ -782,9 +795,9 @@ screen_login_customization() {
|
||||
echo ""
|
||||
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL"
|
||||
|
||||
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
echo ""
|
||||
note "No branding configured. The login page will show defaults."
|
||||
note "No branding configured. The app will use defaults."
|
||||
fi
|
||||
|
||||
draw_footer
|
||||
@@ -892,13 +905,19 @@ screen_summary() {
|
||||
|
||||
# Login page
|
||||
echo -e " ${CYAN}${BOLD}BRANDING${RESET}"
|
||||
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
[[ -n "$CFG_FAVICON_URL" ]] && \
|
||||
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}"
|
||||
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
|
||||
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
|
||||
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
|
||||
echo -e " App Logo (dark) ....... ${BOLD}${CFG_APP_LOGO_DARK_URL}${RESET}"
|
||||
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && \
|
||||
echo -e " Company Name .......... ${BOLD}${CFG_LOGIN_COMPANY_NAME}${RESET}"
|
||||
[[ -n "$CFG_LOGIN_LOGO_LIGHT_URL" ]] && \
|
||||
echo -e " Logo (light mode) ..... ${BOLD}${CFG_LOGIN_LOGO_LIGHT_URL}${RESET}"
|
||||
echo -e " Login Logo (light) .... ${BOLD}${CFG_LOGIN_LOGO_LIGHT_URL}${RESET}"
|
||||
[[ -n "$CFG_LOGIN_LOGO_DARK_URL" ]] && \
|
||||
echo -e " Logo (dark mode) ...... ${BOLD}${CFG_LOGIN_LOGO_DARK_URL}${RESET}"
|
||||
echo -e " Login Logo (dark) ..... ${BOLD}${CFG_LOGIN_LOGO_DARK_URL}${RESET}"
|
||||
[[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && \
|
||||
echo -e " Website URL ........... ${BOLD}${CFG_LOGIN_WEBSITE_URL}${RESET}"
|
||||
[[ -n "$CFG_LOGIN_IMPRINT_URL" ]] && \
|
||||
@@ -1003,9 +1022,12 @@ ENVEOF
|
||||
LOG_FORMAT=${CFG_LOG_FORMAT}
|
||||
LOG_LEVEL=${CFG_LOG_LEVEL}
|
||||
|
||||
# -- Login Page Customization --------------------------------------------------
|
||||
# -- Branding ------------------------------------------------------------------
|
||||
ENVEOF
|
||||
|
||||
[[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_LOGO_LIGHT_URL" ]] && echo "LOGIN_LOGO_LIGHT_URL=${CFG_LOGIN_LOGO_LIGHT_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_LOGO_DARK_URL" ]] && echo "LOGIN_LOGO_DARK_URL=${CFG_LOGIN_LOGO_DARK_URL}" >> "$ENV_FILE"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,12 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||
|
||||
const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda'];
|
||||
|
||||
export function isCalendarViewMode(value: unknown): value is CalendarViewMode {
|
||||
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
|
||||
}
|
||||
|
||||
export interface ICalSubscription {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -71,6 +77,10 @@ const initialState = {
|
||||
icalSubscriptions: [] as ICalSubscription[],
|
||||
};
|
||||
|
||||
function getSafeCalendarViewMode(value: unknown): CalendarViewMode {
|
||||
return isCalendarViewMode(value) ? value : 'month';
|
||||
}
|
||||
|
||||
export const useCalendarStore = create<CalendarStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -299,7 +309,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
},
|
||||
|
||||
setSelectedDate: (date) => set({ selectedDate: date }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setViewMode: (mode) => set({ viewMode: getSafeCalendarViewMode(mode) }),
|
||||
|
||||
updateCalendar: async (client, calendarId, updates) => {
|
||||
set({ error: null });
|
||||
@@ -575,6 +585,17 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'calendar-storage',
|
||||
merge: (persistedState, currentState) => {
|
||||
const mergedState = {
|
||||
...currentState,
|
||||
...(persistedState as Partial<CalendarStore> | undefined),
|
||||
};
|
||||
|
||||
return {
|
||||
...mergedState,
|
||||
viewMode: getSafeCalendarViewMode(mergedState.viewMode),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
selectedCalendarIds: state.selectedCalendarIds,
|
||||
viewMode: state.viewMode,
|
||||
|
||||
@@ -62,6 +62,7 @@ interface EmailStore {
|
||||
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: JMAPClient) => Promise<void>;
|
||||
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string) => Promise<void>;
|
||||
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
||||
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
@@ -402,6 +403,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
sendRawEmail: async (client, rawMimeBlob, identityId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const mailboxes = await client.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
if (!sentMailbox) throw new Error('No sent mailbox found');
|
||||
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id);
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteEmail: async (client, emailId, forceDelete) => {
|
||||
try {
|
||||
// Get the email to check if it's unread and which mailboxes it belongs to
|
||||
|
||||
@@ -30,6 +30,7 @@ export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
||||
export type MailAttachmentAction = 'preview' | 'download';
|
||||
export type ToolbarPosition = 'top' | 'below-subject';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
|
||||
export interface KeywordDefinition {
|
||||
id: string; // Used as JMAP keyword suffix: $label:<id>
|
||||
@@ -37,6 +38,14 @@ export interface KeywordDefinition {
|
||||
color: string; // Key from KEYWORD_PALETTE
|
||||
}
|
||||
|
||||
export interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string; // Lucide icon name (e.g. 'Globe', 'Rss')
|
||||
openMode: 'tab' | 'inline'; // Open in new tab or embed inline
|
||||
}
|
||||
|
||||
// Available color palette for keywords
|
||||
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
|
||||
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
|
||||
@@ -83,6 +92,8 @@ interface SettingsState {
|
||||
emailsPerPage: number;
|
||||
externalContentPolicy: ExternalContentPolicy;
|
||||
mailAttachmentAction: MailAttachmentAction;
|
||||
emailAlwaysLightMode: boolean; // Always render email content in light mode
|
||||
archiveMode: ArchiveMode; // How to organize archived emails: single folder, by year, or by year+month
|
||||
|
||||
// Composer
|
||||
autoSaveDraftInterval: number; // milliseconds
|
||||
@@ -110,6 +121,11 @@ interface SettingsState {
|
||||
|
||||
// Keywords (labels/tags)
|
||||
emailKeywords: KeywordDefinition[];
|
||||
|
||||
// Sidebar Apps
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
settingsSyncDisabled: boolean;
|
||||
@@ -139,6 +155,12 @@ interface SettingsState {
|
||||
reorderKeywords: (keywords: KeywordDefinition[]) => void;
|
||||
getKeywordById: (id: string) => KeywordDefinition | undefined;
|
||||
|
||||
// Sidebar Apps
|
||||
addSidebarApp: (app: SidebarApp) => void;
|
||||
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
|
||||
removeSidebarApp: (id: string) => void;
|
||||
reorderSidebarApps: (apps: SidebarApp[]) => void;
|
||||
|
||||
// Settings sync
|
||||
enableSync: (username: string, serverUrl: string) => void;
|
||||
disableSync: () => void;
|
||||
@@ -164,6 +186,8 @@ const DEFAULT_SETTINGS = {
|
||||
emailsPerPage: 50,
|
||||
externalContentPolicy: 'ask' as ExternalContentPolicy,
|
||||
mailAttachmentAction: 'preview' as MailAttachmentAction,
|
||||
emailAlwaysLightMode: false,
|
||||
archiveMode: 'single' as ArchiveMode,
|
||||
|
||||
// Composer
|
||||
autoSaveDraftInterval: 60000, // 1 minute
|
||||
@@ -192,6 +216,10 @@ const DEFAULT_SETTINGS = {
|
||||
// Keywords
|
||||
emailKeywords: DEFAULT_KEYWORDS,
|
||||
|
||||
// Sidebar Apps
|
||||
sidebarApps: [] as SidebarApp[],
|
||||
keepAppsLoaded: false,
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
settingsSyncDisabled: false,
|
||||
@@ -243,6 +271,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
emailsPerPage: state.emailsPerPage,
|
||||
externalContentPolicy: state.externalContentPolicy,
|
||||
mailAttachmentAction: state.mailAttachmentAction,
|
||||
archiveMode: state.archiveMode,
|
||||
trustedSenders: state.trustedSenders,
|
||||
autoSaveDraftInterval: state.autoSaveDraftInterval,
|
||||
sendConfirmation: state.sendConfirmation,
|
||||
@@ -255,6 +284,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
senderFavicons: state.senderFavicons,
|
||||
folderIcons: state.folderIcons,
|
||||
emailKeywords: state.emailKeywords,
|
||||
sidebarApps: state.sidebarApps,
|
||||
keepAppsLoaded: state.keepAppsLoaded,
|
||||
debugMode: state.debugMode,
|
||||
settingsSyncDisabled: state.settingsSyncDisabled,
|
||||
// Cross-store settings
|
||||
@@ -358,6 +389,29 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
return get().emailKeywords.find(k => k.id === id);
|
||||
},
|
||||
|
||||
// Sidebar Apps methods
|
||||
addSidebarApp: (app: SidebarApp) => {
|
||||
const current = get().sidebarApps;
|
||||
if (current.some(a => a.id === app.id)) return;
|
||||
set({ sidebarApps: [...current, app] });
|
||||
},
|
||||
|
||||
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => {
|
||||
set({
|
||||
sidebarApps: get().sidebarApps.map(a =>
|
||||
a.id === id ? { ...a, ...updates } : a
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
removeSidebarApp: (id: string) => {
|
||||
set({ sidebarApps: get().sidebarApps.filter(a => a.id !== id) });
|
||||
},
|
||||
|
||||
reorderSidebarApps: (apps: SidebarApp[]) => {
|
||||
set({ sidebarApps: apps });
|
||||
},
|
||||
|
||||
// Settings sync methods
|
||||
enableSync: (username: string, serverUrl: string) => {
|
||||
syncUsername = username;
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
||||
import {
|
||||
saveKeyRecord,
|
||||
listKeyRecords,
|
||||
deleteKeyRecord as deleteKeyRecordDB,
|
||||
savePublicCert,
|
||||
listPublicCerts,
|
||||
deletePublicCert as deletePublicCertDB,
|
||||
} from '@/lib/smime/key-storage';
|
||||
import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import';
|
||||
import {
|
||||
parseCertificatePemOrDer,
|
||||
extractCertificateInfo,
|
||||
} from '@/lib/smime/certificate-utils';
|
||||
|
||||
const REMEMBERED_UNLOCKS_STORAGE_KEY = 'smime-unlocked-session';
|
||||
|
||||
type RememberedUnlocks = Record<string, string>;
|
||||
|
||||
function readRememberedUnlocks(): RememberedUnlocks {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(REMEMBERED_UNLOCKS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rememberedUnlocks: RememberedUnlocks = {};
|
||||
for (const [keyId, passphrase] of Object.entries(parsed)) {
|
||||
if (typeof passphrase === 'string') {
|
||||
rememberedUnlocks[keyId] = passphrase;
|
||||
}
|
||||
}
|
||||
|
||||
return rememberedUnlocks;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRememberedUnlocks(rememberedUnlocks: RememberedUnlocks): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Object.keys(rememberedUnlocks).length === 0) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_UNLOCKS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_UNLOCKS_STORAGE_KEY,
|
||||
JSON.stringify(rememberedUnlocks),
|
||||
);
|
||||
} catch {
|
||||
// Ignore unavailable or blocked session storage.
|
||||
}
|
||||
}
|
||||
|
||||
function rememberUnlockedKey(keyId: string, passphrase: string): void {
|
||||
const rememberedUnlocks = readRememberedUnlocks();
|
||||
rememberedUnlocks[keyId] = passphrase;
|
||||
writeRememberedUnlocks(rememberedUnlocks);
|
||||
}
|
||||
|
||||
function forgetUnlockedKey(keyId: string): void {
|
||||
const rememberedUnlocks = readRememberedUnlocks();
|
||||
if (!(keyId in rememberedUnlocks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete rememberedUnlocks[keyId];
|
||||
writeRememberedUnlocks(rememberedUnlocks);
|
||||
}
|
||||
|
||||
function clearRememberedUnlocks(): void {
|
||||
writeRememberedUnlocks({});
|
||||
}
|
||||
|
||||
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
||||
unlockedKeys: Map<string, CryptoKey>;
|
||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||
}> {
|
||||
const rememberedUnlocks = readRememberedUnlocks();
|
||||
const unlockedKeys = new Map<string, CryptoKey>();
|
||||
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
|
||||
let removedStaleEntries = false;
|
||||
|
||||
for (const record of keyRecords) {
|
||||
const passphrase = rememberedUnlocks[record.id];
|
||||
if (!passphrase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||
unlockedKeys.set(record.id, signingKey);
|
||||
if (decryptionKey) {
|
||||
unlockedDecryptionKeys.set(record.id, decryptionKey);
|
||||
}
|
||||
} catch {
|
||||
delete rememberedUnlocks[record.id];
|
||||
removedStaleEntries = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedStaleEntries) {
|
||||
writeRememberedUnlocks(rememberedUnlocks);
|
||||
}
|
||||
|
||||
return { unlockedKeys, unlockedDecryptionKeys };
|
||||
}
|
||||
|
||||
interface SmimePersistedState {
|
||||
identityKeyBindings: Record<string, string>; // identityId → keyRecordId
|
||||
defaultSignIdentity: Record<string, boolean>; // identityId → sign by default
|
||||
defaultEncrypt: boolean;
|
||||
rememberUnlockedKeys: boolean;
|
||||
autoImportSignerCerts: boolean;
|
||||
}
|
||||
|
||||
interface SmimeStore extends SmimePersistedState {
|
||||
// Loaded from IndexedDB
|
||||
keyRecords: SmimeKeyRecord[];
|
||||
publicCerts: SmimePublicCert[];
|
||||
// Runtime only — never persisted
|
||||
unlockedKeys: Map<string, CryptoKey>;
|
||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
load: () => Promise<void>;
|
||||
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
|
||||
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
|
||||
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
|
||||
removeKeyRecord: (id: string) => Promise<void>;
|
||||
removePublicCert: (id: string) => Promise<void>;
|
||||
unlockKey: (id: string, passphrase: string) => Promise<void>;
|
||||
lockKey: (id: string) => void;
|
||||
lockAllKeys: () => void;
|
||||
getKeyRecordForIdentity: (identityId: string) => SmimeKeyRecord | undefined;
|
||||
getPublicCertForEmail: (email: string) => SmimePublicCert | undefined;
|
||||
getRecipientCerts: (emails: string[]) => { found: SmimePublicCert[]; missing: string[] };
|
||||
setSignDefault: (identityId: string, value: boolean) => void;
|
||||
setEncryptDefault: (value: boolean) => void;
|
||||
setRememberUnlockedKeys: (value: boolean) => void;
|
||||
setAutoImportSignerCerts: (value: boolean) => void;
|
||||
isKeyUnlocked: (id: string) => boolean;
|
||||
getUnlockedKey: (id: string) => CryptoKey | undefined;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export const useSmimeStore = create<SmimeStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Persisted preferences
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: false,
|
||||
|
||||
// Runtime state
|
||||
keyRecords: [],
|
||||
publicCerts: [],
|
||||
unlockedKeys: new Map(),
|
||||
unlockedDecryptionKeys: new Map(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
load: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [keyRecords, publicCerts] = await Promise.all([
|
||||
listKeyRecords(),
|
||||
listPublicCerts(),
|
||||
]);
|
||||
|
||||
if (get().rememberUnlockedKeys) {
|
||||
const restoredKeys = await restoreRememberedKeys(keyRecords);
|
||||
set((state) => ({
|
||||
keyRecords,
|
||||
publicCerts,
|
||||
unlockedKeys: new Map([
|
||||
...state.unlockedKeys,
|
||||
...restoredKeys.unlockedKeys,
|
||||
]),
|
||||
unlockedDecryptionKeys: new Map([
|
||||
...state.unlockedDecryptionKeys,
|
||||
...restoredKeys.unlockedDecryptionKeys,
|
||||
]),
|
||||
isLoading: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set({ keyRecords, publicCerts, isLoading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to load S/MIME data',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
importPKCS12: async (file, p12Passphrase, storagePassphrase) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase);
|
||||
await saveKeyRecord(keyRecord);
|
||||
set((state) => ({
|
||||
keyRecords: [...state.keyRecords, keyRecord],
|
||||
isLoading: false,
|
||||
}));
|
||||
return keyRecord;
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to import PKCS#12',
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
importPublicCert: async (data, source, contactId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const cert = parseCertificatePemOrDer(data);
|
||||
// Always re-encode to DER — input might be PEM text (string or ArrayBuffer)
|
||||
const der = cert.toSchema(true).toBER(false);
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
const email = info.emailAddresses[0] ?? '';
|
||||
|
||||
const publicCert: SmimePublicCert = {
|
||||
id: crypto.randomUUID(),
|
||||
email: email.toLowerCase(),
|
||||
certificate: der,
|
||||
issuer: info.issuer,
|
||||
subject: info.subject,
|
||||
notBefore: info.notBefore,
|
||||
notAfter: info.notAfter,
|
||||
fingerprint: info.fingerprint,
|
||||
source,
|
||||
contactId,
|
||||
};
|
||||
|
||||
await savePublicCert(publicCert);
|
||||
set((state) => ({
|
||||
publicCerts: [...state.publicCerts, publicCert],
|
||||
isLoading: false,
|
||||
}));
|
||||
return publicCert;
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to import certificate',
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
bindIdentityToKey: (identityId, keyRecordId) => {
|
||||
set((state) => {
|
||||
const bindings = { ...state.identityKeyBindings };
|
||||
if (keyRecordId === null) {
|
||||
delete bindings[identityId];
|
||||
} else {
|
||||
bindings[identityId] = keyRecordId;
|
||||
}
|
||||
return { identityKeyBindings: bindings };
|
||||
});
|
||||
},
|
||||
|
||||
removeKeyRecord: async (id) => {
|
||||
await deleteKeyRecordDB(id);
|
||||
forgetUnlockedKey(id);
|
||||
set((state) => {
|
||||
const unlockedKeys = new Map(state.unlockedKeys);
|
||||
unlockedKeys.delete(id);
|
||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||
unlockedDecryptionKeys.delete(id);
|
||||
// Remove any identity bindings pointing to this key
|
||||
const bindings = { ...state.identityKeyBindings };
|
||||
for (const [identityId, keyId] of Object.entries(bindings)) {
|
||||
if (keyId === id) delete bindings[identityId];
|
||||
}
|
||||
return {
|
||||
keyRecords: state.keyRecords.filter((k) => k.id !== id),
|
||||
unlockedKeys,
|
||||
unlockedDecryptionKeys,
|
||||
identityKeyBindings: bindings,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removePublicCert: async (id) => {
|
||||
await deletePublicCertDB(id);
|
||||
set((state) => ({
|
||||
publicCerts: state.publicCerts.filter((c) => c.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
unlockKey: async (id, passphrase) => {
|
||||
const record = get().keyRecords.find((k) => k.id === id);
|
||||
if (!record) throw new Error('Key record not found');
|
||||
|
||||
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||
if (get().rememberUnlockedKeys) {
|
||||
rememberUnlockedKey(id, passphrase);
|
||||
}
|
||||
set((state) => {
|
||||
const unlockedKeys = new Map(state.unlockedKeys);
|
||||
unlockedKeys.set(id, signingKey);
|
||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||
if (decryptionKey) {
|
||||
unlockedDecryptionKeys.set(id, decryptionKey);
|
||||
}
|
||||
return { unlockedKeys, unlockedDecryptionKeys };
|
||||
});
|
||||
},
|
||||
|
||||
lockKey: (id) => {
|
||||
forgetUnlockedKey(id);
|
||||
set((state) => {
|
||||
const unlockedKeys = new Map(state.unlockedKeys);
|
||||
unlockedKeys.delete(id);
|
||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||
unlockedDecryptionKeys.delete(id);
|
||||
return { unlockedKeys, unlockedDecryptionKeys };
|
||||
});
|
||||
},
|
||||
|
||||
lockAllKeys: () => {
|
||||
clearRememberedUnlocks();
|
||||
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() });
|
||||
},
|
||||
|
||||
getKeyRecordForIdentity: (identityId) => {
|
||||
const { identityKeyBindings, keyRecords } = get();
|
||||
const keyId = identityKeyBindings[identityId];
|
||||
if (!keyId) return undefined;
|
||||
return keyRecords.find((k) => k.id === keyId);
|
||||
},
|
||||
|
||||
getPublicCertForEmail: (email) => {
|
||||
return get().publicCerts.find(
|
||||
(c) => c.email.toLowerCase() === email.toLowerCase(),
|
||||
);
|
||||
},
|
||||
|
||||
getRecipientCerts: (emails) => {
|
||||
const { publicCerts } = get();
|
||||
const found: SmimePublicCert[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const email of emails) {
|
||||
const cert = publicCerts.find(
|
||||
(c) => c.email.toLowerCase() === email.toLowerCase(),
|
||||
);
|
||||
if (cert) {
|
||||
found.push(cert);
|
||||
} else {
|
||||
missing.push(email);
|
||||
}
|
||||
}
|
||||
return { found, missing };
|
||||
},
|
||||
|
||||
setSignDefault: (identityId, value) => {
|
||||
set((state) => ({
|
||||
defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value },
|
||||
}));
|
||||
},
|
||||
|
||||
setEncryptDefault: (value) => {
|
||||
set({ defaultEncrypt: value });
|
||||
},
|
||||
|
||||
setRememberUnlockedKeys: (value) => {
|
||||
set({ rememberUnlockedKeys: value });
|
||||
if (!value) {
|
||||
clearRememberedUnlocks();
|
||||
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() });
|
||||
}
|
||||
},
|
||||
|
||||
setAutoImportSignerCerts: (value) => {
|
||||
set({ autoImportSignerCerts: value });
|
||||
},
|
||||
|
||||
isKeyUnlocked: (id) => get().unlockedKeys.has(id),
|
||||
|
||||
getUnlockedKey: (id) => get().unlockedKeys.get(id),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
}),
|
||||
{
|
||||
name: 'smime-preferences',
|
||||
partialize: (state): SmimePersistedState => ({
|
||||
identityKeyBindings: state.identityKeyBindings,
|
||||
defaultSignIdentity: state.defaultSignIdentity,
|
||||
defaultEncrypt: state.defaultEncrypt,
|
||||
rememberUnlockedKeys: state.rememberUnlockedKeys,
|
||||
autoImportSignerCerts: state.autoImportSignerCerts,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user