fix: standardize punctuation
This commit is contained in:
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
# Bulwark Webmail — Development Configuration
|
# Bulwark Webmail - Development Configuration
|
||||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||||
# No external mail server required — great for UI development and testing.
|
# No external mail server required - great for UI development and testing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.dev.example .env.local
|
# cp .env.dev.example .env.local
|
||||||
# npm run dev
|
# npm run dev
|
||||||
# Open http://localhost:3000 — log in with any username/password.
|
# Open http://localhost:3000 - log in with any username/password.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock JMAP Server
|
# Mock JMAP Server
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Bulwark Webmail — Production Configuration
|
# Bulwark Webmail - Production Configuration
|
||||||
# Copy this file to .env.local and fill in your values.
|
# Copy this file to .env.local and fill in your values.
|
||||||
# For development with the built-in mock server, see .env.dev.example instead.
|
# For development with the built-in mock server, see .env.dev.example instead.
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, the working directory is /app, so the default resolves to
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
# /app/data/settings — mount a persistent volume there:
|
# /app/data/settings - mount a persistent volume there:
|
||||||
# volumes:
|
# volumes:
|
||||||
# - bulwark-settings:/app/data/settings
|
# - bulwark-settings:/app/data/settings
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
|
|||||||
+8
-4
@@ -6,14 +6,14 @@ Thank you for your donations:
|
|||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
- [@boris22100](https://github.com/boris22100)
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
- [@pr0ton11](https://github.com/pr0ton11)
|
|
||||||
|
|
||||||
|
- [@pr0ton11](https://github.com/pr0ton11)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -45,10 +45,12 @@ Thank you for your donations:
|
|||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@boris22100](https://github.com/boris22100)
|
- [@boris22100](https://github.com/boris22100)
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -78,9 +80,11 @@ Thank you for your donations:
|
|||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -476,7 +480,7 @@ Thank you for your donations:
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||||
|
|
||||||
## 1.1.1 (2026-02-28)
|
## 1.1.1 (2026-02-28)
|
||||||
@@ -486,7 +490,7 @@ Thank you for your donations:
|
|||||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||||
|
|
||||||
## 1.1.0 (2026-02-28)
|
## 1.1.0 (2026-02-28)
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<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>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
<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>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<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>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||||
|
|
||||||
**Settings** — Appearance, identities, filters, templates, and more
|
**Settings** - Appearance, identities, filters, templates, and more
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
<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>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
@@ -95,29 +95,29 @@ Built with Next.js and the JMAP protocol.
|
|||||||
### Mail
|
### Mail
|
||||||
|
|
||||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||||
- **Threading** — Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||||
- **Unified mailbox** — view emails across all accounts in a single list
|
- **Unified mailbox** - view emails across all accounts in a single list
|
||||||
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||||
- **Attachments** — upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||||
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
- **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
|
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||||
- **Archive modes** — archive directly or organize archived mail by year or month
|
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||||
- **Print** emails directly from the viewer
|
- **Print** emails directly from the viewer
|
||||||
- **Answered/forwarded status icons** in email list and thread views
|
- **Answered/forwarded status icons** in email list and thread views
|
||||||
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||||
- **Star/unstar** with configurable mark-as-read delay
|
- **Star/unstar** with configurable mark-as-read delay
|
||||||
- **Virtual scrolling** for large mailboxes
|
- **Virtual scrolling** for large mailboxes
|
||||||
- **Quick reply** from the viewer
|
- **Quick reply** from the viewer
|
||||||
- **Hover actions** — configurable quick-action buttons on email rows with customizable placement
|
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||||
- **Sender avatars** — favicon-based with negative caching for performance
|
- **Sender avatars** - favicon-based with negative caching for performance
|
||||||
- **Recipient popover** for quick contact interaction
|
- **Recipient popover** for quick contact interaction
|
||||||
- **Plain text composer mode** and auto-select reply identity
|
- **Plain text composer mode** and auto-select reply identity
|
||||||
- **Reply-to addresses** support in the composer
|
- **Reply-to addresses** support in the composer
|
||||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||||
- **message/rfc822 unwrapping** for embedded messages
|
- **message/rfc822 unwrapping** for embedded messages
|
||||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||||
- **Tag counts** — unread and total counts displayed in sidebar
|
- **Tag counts** - unread and total counts displayed in sidebar
|
||||||
- **Browser history sync** — back/forward navigation mirrors mail view state
|
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
|
|
||||||
@@ -125,13 +125,13 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Event hover preview** popover with configurable details
|
- **Event hover preview** popover with configurable details
|
||||||
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||||
- **Participant scheduling** — iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||||
- **iCalendar import** with preview, bulk create, and UID deduplication
|
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||||
- **iCal / webcal subscriptions** with editing and batch import
|
- **iCal / webcal subscriptions** with editing and batch import
|
||||||
- **Birthday calendar** — auto-generated from contacts
|
- **Birthday calendar** - auto-generated from contacts
|
||||||
- **Virtual locations** — video conference URLs as first-class event fields
|
- **Virtual locations** - video conference URLs as first-class event fields
|
||||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||||
- **Shared calendars** with visual grouping in the sidebar
|
- **Shared calendars** with visual grouping in the sidebar
|
||||||
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||||
- **Week numbers** in mini-calendar sidebar
|
- **Week numbers** in mini-calendar sidebar
|
||||||
@@ -141,66 +141,66 @@ Built with Next.js and the JMAP protocol.
|
|||||||
### Contacts
|
### Contacts
|
||||||
|
|
||||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||||
- **Multiple address books** — create, rename, drag-and-drop between books, with editor picker in contact form
|
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||||
- **Collapsible sidebar** with address book grouping and bulk operations
|
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||||
- **Contact groups** with group expansion and member management
|
- **Contact groups** with group expansion and member management
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||||
- **Trusted senders** stored in a dedicated JMAP address book
|
- **Trusted senders** stored in a dedicated JMAP address book
|
||||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||||
- **Bulk operations** — multi-select, delete, group add, export
|
- **Bulk operations** - multi-select, delete, group add, export
|
||||||
|
|
||||||
### Filters & Automation
|
### Filters & Automation
|
||||||
|
|
||||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||||
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||||
- **External rule preservation** — rules authored in other clients are displayed and preserved
|
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||||
- **Raw Sieve editor** with syntax validation
|
- **Raw Sieve editor** with syntax validation
|
||||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
- **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
|
### Files
|
||||||
|
|
||||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||||
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||||
- **Dynamic upload limits** — respects the server-configured maximum upload size
|
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||||
- **Grid and list views** with sorting by name, size, or date
|
- **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
|
- **File preview** for images, text, audio, video, and more
|
||||||
- **Favorites and recent files** for quick access
|
- **Favorites and recent files** for quick access
|
||||||
- **Bulk operations** — multi-select, delete, move, download
|
- **Bulk operations** - multi-select, delete, move, download
|
||||||
|
|
||||||
### Security & Privacy
|
### 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
|
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||||
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
- **SPF/DKIM/DMARC** status indicators
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||||
- **TOTP two-factor authentication**
|
- **TOTP two-factor authentication**
|
||||||
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||||
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||||
- **Security headers** — enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||||
- **Plugin hardening** — dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
- **Newsletter unsubscribe** (RFC 2369)
|
||||||
|
|
||||||
### Interface
|
### 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
|
- **Dark and light themes** with intelligent email color transformation
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||||
- **Keyboard shortcuts** — full navigation without a mouse
|
- **Keyboard shortcuts** - full navigation without a mouse
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||||
- **Interactive guided tour** — onboarding walkthrough for new users
|
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||||
- **Sidebar apps** — pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||||
- **Storage quota** display
|
- **Storage quota** display
|
||||||
- **Version badge** in settings
|
- **Version badge** in settings
|
||||||
- **Focused mode** with proper viewport bounds
|
- **Focused mode** with proper viewport bounds
|
||||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
|
|
||||||
@@ -212,32 +212,32 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
- **Multiple sender identities** with per-identity signatures
|
||||||
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||||
- **Identity badges** in viewer and email list
|
- **Identity badges** in viewer and email list
|
||||||
|
|
||||||
### Multi-Account
|
### Multi-Account
|
||||||
|
|
||||||
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||||
- **Account switcher** with connection status, default account selection, and per-account logout
|
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||||
- **Per-account settings** — encrypted settings storage with server-side sync
|
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||||
- **Shared folders** across accounts
|
- **Shared folders** across accounts
|
||||||
- **Custom JMAP server endpoints** — optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
### Admin & Extensibility
|
### Admin & Extensibility
|
||||||
|
|
||||||
- **Stalwart admin dashboard** — sidebar access with reorganized dashboard and dedicated policy sections
|
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||||
- **Plugin system** — schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||||
- **Themes** — upload, enforce, and manage admin-controlled themes with ZIP bundles
|
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||||
- **Extension marketplace** — browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||||
- **Bundled plugins** — Jitsi Meet calendar integration
|
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Progressive Web App (PWA)** — installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
- **Automatic update check** - server logs when a newer release is available
|
||||||
- **Logging categories** with `text` or `json` formats for log aggregation
|
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||||
- **Docker images** — release (`main`) and development (`dev`) channels on GHCR
|
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local - set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local - set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server Listen Address</summary>
|
||||||
@@ -424,7 +424,7 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a 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
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
|||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
if (!state || state !== savedState) {
|
if (!state || state !== savedState) {
|
||||||
setError("invalid_state");
|
setError("invalid_state");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
window.top!.location.href = authorize_url;
|
window.top!.location.href = authorize_url;
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restriction — fall back to current frame
|
// Cross-origin restriction - fall back to current frame
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -759,7 +759,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Dev mode — logging in as dev@localhost
|
Dev mode - logging in as dev@localhost
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : oauthOnly ? (
|
) : oauthOnly ? (
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Restore conversation thread (mobile only). We can clear it directly,
|
// Restore conversation thread (mobile only). We can clear it directly,
|
||||||
// but reopening requires the thread group; if the user pressed forward
|
// but reopening requires the thread group; if the user pressed forward
|
||||||
// to return to a thread, we silently skip — back navigation always works.
|
// to return to a thread, we silently skip - back navigation always works.
|
||||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||||
if (state.threadId === null) {
|
if (state.threadId === null) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
@@ -826,7 +826,7 @@ export default function Home() {
|
|||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
// archiveMode === 'month' - find or create month subfolder under year
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
let monthMailbox = mailboxes.find(
|
let monthMailbox = mailboxes.find(
|
||||||
m => m.name === month && m.parentId === yearId
|
m => m.name === month && m.parentId === yearId
|
||||||
@@ -1255,7 +1255,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Handle back navigation from viewer on mobile.
|
// Handle back navigation from viewer on mobile.
|
||||||
// Delegate to the browser history stack so this button is equivalent to
|
// Delegate to the browser history stack so this button is equivalent to
|
||||||
// the OS back button / mouse back button — popstate then restores the
|
// the OS back button / mouse back button - popstate then restores the
|
||||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||||
// from a state that pushed history, so back() always lands on an app entry.
|
// from a state that pushed history, so back() always lands on an app entry.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export default function AdminLogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDetail(detail: Record<string, unknown>): string {
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||||
if (detail.reason) return String(detail.reason);
|
if (detail.reason) return String(detail.reason);
|
||||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
if (detail.changes && Array.isArray(detail.changes)) {
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -99,8 +99,8 @@ export default function AdminDashboardPage() {
|
|||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '—';
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="max-w-3xl space-y-8">
|
||||||
@@ -127,9 +127,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Server Info */}
|
{/* Server Info */}
|
||||||
<SettingsSection title="Server" description="Application and connection details">
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
<SettingItem label="Application">
|
<SettingItem label="Application">
|
||||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Connection">
|
<SettingItem label="JMAP Connection">
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export default function PluginConfigPage() {
|
|||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">— Select —</option>
|
<option value="">- Select -</option>
|
||||||
{field.options.map(opt => (
|
{field.options.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/auth — Login
|
* POST /api/admin/auth - Login
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/auth — Check session status
|
* GET /api/admin/auth - Check session status
|
||||||
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/auth — Logout
|
* DELETE /api/admin/auth - Logout
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||||
*
|
*
|
||||||
* This endpoint is public (no admin auth) so browsers can load images.
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
* Only files in the branding directory are served; directory traversal is prevented.
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/branding — Upload a branding image file
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
*
|
*
|
||||||
* Expects JSON body: { slot: string }
|
* Expects JSON body: { slot: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/change-password — Change admin password
|
* POST /api/admin/change-password - Change admin password
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
|||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
* Proxies to the extension directory API
|
* Proxies to the extension directory API
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/marketplace — Install an extension from the directory
|
* POST /api/admin/marketplace - Install an extension from the directory
|
||||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
*
|
*
|
||||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/a
|
|||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
||||||
*
|
*
|
||||||
* Returns the full config object for admin-configured plugin settings.
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
* This endpoint is accessible from the client-side plugin API.
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
@@ -35,7 +35,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/plugins/[id]/config — Set a config key
|
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string, value: unknown }
|
* Body: { key: string, value: unknown }
|
||||||
* Requires admin authentication (checked via admin session).
|
* Requires admin authentication (checked via admin session).
|
||||||
@@ -83,7 +83,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string }
|
* Body: { key: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins — List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +41,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -218,7 +218,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins — Remove a plugin
|
* DELETE /api/admin/plugins - Remove a plugin
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +22,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/stalwart-check
|
* GET /api/admin/stalwart-check
|
||||||
* Check if the currently logged-in user is a Stalwart admin.
|
* Check if the currently logged-in user is a Stalwart admin.
|
||||||
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
|||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes — List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +32,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/themes — Remove a theme
|
* DELETE /api/admin/themes - Remove a theme
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT — retrieve full credentials (including password) for session restoration.
|
* PUT - retrieve full credentials (including password) for session restoration.
|
||||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network error — endpoint not reachable
|
// Network error - endpoint not reachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
*
|
*
|
||||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
* No admin auth required — this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce admin policy — strip locked settings so users can't override them
|
// Enforce admin policy - strip locked settings so users can't override them
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
const filteredSettings = { ...settings };
|
const filteredSettings = { ...settings };
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
try {
|
try {
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
let derBytes: ArrayBuffer | string | null = null;
|
||||||
if (key.uri.startsWith('data:')) {
|
if (key.uri.startsWith('data:')) {
|
||||||
// data URI — extract base64 content
|
// data URI - extract base64 content
|
||||||
const commaIdx = key.uri.indexOf(',');
|
const commaIdx = key.uri.indexOf(',');
|
||||||
if (commaIdx === -1) continue;
|
if (commaIdx === -1) continue;
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
const b64 = key.uri.substring(commaIdx + 1);
|
||||||
@@ -178,7 +178,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||||
{hasNickname && (
|
{hasNickname && (
|
||||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +278,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -371,7 +371,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{contact.speakToAs.pronouns && (() => {
|
{contact.speakToAs.pronouns && (() => {
|
||||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
return firstPronoun ? (
|
return firstPronoun ? (
|
||||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name & Identity — full width */}
|
{/* Name & Identity - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
@@ -575,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setEmails(next);
|
setEmails(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -639,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setPhones(next);
|
setPhones(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -677,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Addresses — full width */}
|
{/* Addresses - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -698,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
value={addr.context}
|
value={addr.context}
|
||||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -833,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="masculine">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="feminine">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="other">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
@@ -866,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Notes — full width */}
|
{/* Notes - full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||||
{cEmail && cName && (
|
{cEmail && cName && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{name || email || "—"}
|
{name || email || "-"}
|
||||||
</div>
|
</div>
|
||||||
{density !== 'extra-compact' && email && name && (
|
{density !== 'extra-compact' && email && name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| null;
|
|| null;
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||||
// Called after updating the local calendar event. Best-effort — if it
|
// Called after updating the local calendar event. Best-effort - if it
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
// fails we still report the RSVP as sent since the calendar was updated.
|
||||||
const sendImipReply = async () => {
|
const sendImipReply = async () => {
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||||
|
|||||||
@@ -740,7 +740,7 @@ function ContactSidebarPanel({
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1170,7 +1170,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already read — record that so manual unread toggle won't re-trigger auto-mark
|
// Already read - record that so manual unread toggle won't re-trigger auto-mark
|
||||||
if (email.keywords?.$seen) {
|
if (email.keywords?.$seen) {
|
||||||
autoMarkedEmailRef.current = email.id;
|
autoMarkedEmailRef.current = email.id;
|
||||||
return;
|
return;
|
||||||
@@ -1644,7 +1644,7 @@ export function EmailViewer({
|
|||||||
// Check if inner content is also signed
|
// Check if inner content is also signed
|
||||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||||
if (nestedSignedData) {
|
if (nestedSignedData) {
|
||||||
// Nested sign-then-encrypt — verify inner signature
|
// Nested sign-then-encrypt - verify inner signature
|
||||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||||
source: nestedSignedData.source,
|
source: nestedSignedData.source,
|
||||||
@@ -1853,7 +1853,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
@@ -1898,7 +1898,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1912,7 +1912,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1975,7 +1975,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -2091,7 +2091,7 @@ export function EmailViewer({
|
|||||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||||
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||||
// banner is shown — prevents raw ICS files appearing as spurious attachments.
|
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||||
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||||
.map((attachment, index) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
@@ -2961,7 +2961,7 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="toolbar-actions" />
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||||
@@ -3058,7 +3058,7 @@ export function EmailViewer({
|
|||||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5" />
|
||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
@@ -3127,7 +3127,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3149,7 +3149,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3162,7 +3162,7 @@ export function EmailViewer({
|
|||||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Print — hidden on mobile, overflows to More menu */}
|
{/* Print - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3176,7 +3176,7 @@ export function EmailViewer({
|
|||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* View source — hidden on mobile, overflows to More menu */}
|
{/* View source - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3204,7 +3204,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More menu — click-based */}
|
{/* More menu - click-based */}
|
||||||
<div ref={moreMenuRef} className="relative">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3250,7 +3250,7 @@ export function EmailViewer({
|
|||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('archive')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
{/* Overflow: move to folder — submenu */}
|
{/* Overflow: move to folder - submenu */}
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('move')}
|
onMouseEnter={() => setMoreMenuSub('move')}
|
||||||
@@ -3302,7 +3302,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag — submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
@@ -4039,7 +4039,7 @@ export function EmailViewer({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||||
Spam Score
|
Spam Score
|
||||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-xs",
|
"text-xs",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Display label override (e.g. "me") */
|
/** Display label override (e.g. "me") */
|
||||||
displayLabel?: string;
|
displayLabel?: string;
|
||||||
/** Called when user clicks "View contact" — receives the contact and email */
|
/** Called when user clicks "View contact" - receives the contact and email */
|
||||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function FileBrowser({
|
|||||||
// Filter and sort resources
|
// Filter and sort resources
|
||||||
const displayResources = useMemo(() => {
|
const displayResources = useMemo(() => {
|
||||||
let filtered = resources;
|
let filtered = resources;
|
||||||
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
|
// In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
|
||||||
if (folderLayout === "sidebar") {
|
if (folderLayout === "sidebar") {
|
||||||
filtered = filtered.filter(r => !r.isDirectory);
|
filtered = filtered.filter(r => !r.isDirectory);
|
||||||
}
|
}
|
||||||
@@ -1466,7 +1466,7 @@ export function FileBrowser({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||||
{resource.isDirectory ? "—" : formatFileSize(resource.contentLength)}
|
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
||||||
{formatDate(resource.lastModified)}
|
{formatDate(resource.lastModified)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't cache empty root results — empty root likely means client wasn't ready yet
|
// Don't cache empty root results - empty root likely means client wasn't ready yet
|
||||||
if (folders.length > 0 || parentId !== null) {
|
if (folders.length > 0 || parentId !== null) {
|
||||||
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function SidebarAppForm({
|
|||||||
{t('icon_label')} <span className="text-destructive">*</span>
|
{t('icon_label')} <span className="text-destructive">*</span>
|
||||||
{SelectedIcon && (
|
{SelectedIcon && (
|
||||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: bo
|
|||||||
function DensityPreview({ density }: { density: Density }) {
|
function DensityPreview({ density }: { density: Density }) {
|
||||||
const cfg = DENSITY_PREVIEW[density];
|
const cfg = DENSITY_PREVIEW[density];
|
||||||
const rows = [
|
const rows = [
|
||||||
{ unread: true, sender: 'Alice Johnson', subject: 'Project update — Q1 roadmap', preview: 'Here are the latest numbers from…' },
|
{ unread: true, sender: 'Alice Johnson', subject: 'Project update - Q1 roadmap', preview: 'Here are the latest numbers from…' },
|
||||||
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
|
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
|
||||||
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
|
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ export function EmailSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Trusted Senders — address book storage */}
|
{/* Trusted Senders - address book storage */}
|
||||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={trustedSendersAddressBook}
|
checked={trustedSendersAddressBook}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return "—";
|
if (bytes === 0) return "-";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ export function FolderSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Folder List — primary section */}
|
{/* Folder List - primary section */}
|
||||||
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
|
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{folderTree.length === 0 ? (
|
{folderTree.length === 0 ? (
|
||||||
@@ -516,7 +516,7 @@ export function FolderSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Standard Folder Roles — advanced section */}
|
{/* Standard Folder Roles - advanced section */}
|
||||||
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
||||||
{STANDARD_ROLES.map((role) => {
|
{STANDARD_ROLES.map((role) => {
|
||||||
// Disambiguate duplicate folder names by appending parent path
|
// Disambiguate duplicate folder names by appending parent path
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function SmimeCertificateModal({
|
|||||||
{ label: t("cert_email"), value: record.email },
|
{ label: t("cert_email"), value: record.email },
|
||||||
{
|
{
|
||||||
label: t("cert_validity"),
|
label: t("cert_validity"),
|
||||||
value: `${new Date(record.notBefore).toLocaleDateString()} — ${new Date(record.notAfter).toLocaleDateString()}`,
|
value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||||
},
|
},
|
||||||
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function TourOverlay() {
|
|||||||
// Wait for target element to appear, then show
|
// Wait for target element to appear, then show
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" - target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
||||||
// instead of disappearing and reappearing
|
// instead of disappearing and reappearing
|
||||||
@@ -178,7 +178,7 @@ export function TourOverlay() {
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
if (attempts >= maxAttempts && !cancelled) {
|
if (attempts >= maxAttempts && !cancelled) {
|
||||||
// Skip this step if element never appears
|
// Skip this step if element never appears
|
||||||
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
|
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED - element never appeared after ${maxAttempts} attempts`);
|
||||||
nextStepRef.current();
|
nextStepRef.current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ export function useBrowserNavigation({
|
|||||||
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
|
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
// Hold the "applying pop" flag for the entire restore — including any
|
// Hold the "applying pop" flag for the entire restore - including any
|
||||||
// async work like fetching email content — so the resulting state
|
// async work like fetching email content - so the resulting state
|
||||||
// updates don't trigger a fresh history push that would undo the
|
// updates don't trigger a fresh history push that would undo the
|
||||||
// user's back / forward navigation.
|
// user's back / forward navigation.
|
||||||
popDepthRef.current += 1;
|
popDepthRef.current += 1;
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { defineRouting } from 'next-intl/routing';
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
// Locale prefix mode can be configured via NEXT_PUBLIC_LOCALE_PREFIX.
|
// Locale prefix mode can be configured via NEXT_PUBLIC_LOCALE_PREFIX.
|
||||||
// - "never" (default): /settings — locale from cookie/Accept-Language
|
// - "never" (default): /settings - locale from cookie/Accept-Language
|
||||||
// - "always": /en/settings — locale always in the URL
|
// - "always": /en/settings - locale always in the URL
|
||||||
// - "as-needed": /settings for default locale, /fr/settings otherwise
|
// - "as-needed": /settings for default locale, /fr/settings otherwise
|
||||||
// When proxying Bulwark under a sub-path (NEXT_PUBLIC_BASE_PATH), "always" is
|
// When proxying Bulwark under a sub-path (NEXT_PUBLIC_BASE_PATH), "always" is
|
||||||
// recommended to avoid next-intl rewrite loops caused by locale detection
|
// recommended to avoid next-intl rewrite loops caused by locale detection
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ if (process.env.NODE_ENV === "production") {
|
|||||||
if (!SEMVER_RE.test(remote)) return;
|
if (!SEMVER_RE.test(remote)) return;
|
||||||
if (compareVersions(current, remote) > 0) {
|
if (compareVersions(current, remote) > 0) {
|
||||||
console.info(
|
console.info(
|
||||||
`Update available: v${remote} — https://github.com/bulwarkmail/webmail`
|
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Mailbox/get', () => {
|
describe('POST /api - Mailbox/get', () => {
|
||||||
it('should return list of mailboxes', async () => {
|
it('should return list of mailboxes', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -88,7 +88,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Email/query', () => {
|
describe('POST /api - Email/query', () => {
|
||||||
it('should filter by mailbox', async () => {
|
it('should filter by mailbox', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -117,7 +117,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Email/get', () => {
|
describe('POST /api - Email/get', () => {
|
||||||
it('should return emails by ids', async () => {
|
it('should return emails by ids', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -150,7 +150,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Email/set', () => {
|
describe('POST /api - Email/set', () => {
|
||||||
it('should update email keywords', async () => {
|
it('should update email keywords', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -166,7 +166,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — Identity/get', () => {
|
describe('POST /api - Identity/get', () => {
|
||||||
it('should return identities', async () => {
|
it('should return identities', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -183,7 +183,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — unknown method', () => {
|
describe('POST /api - unknown method', () => {
|
||||||
it('should return error for unknown methods', async () => {
|
it('should return error for unknown methods', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -199,7 +199,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — back-references', () => {
|
describe('POST /api - back-references', () => {
|
||||||
it('should resolve #ids from Email/query result', async () => {
|
it('should resolve #ids from Email/query result', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -220,7 +220,7 @@ describe('dev-jmap mock server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api — invalid request', () => {
|
describe('POST /api - invalid request', () => {
|
||||||
it('should return 400 for missing methodCalls', async () => {
|
it('should return 400 for missing methodCalls', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('authenticatedFetch — network error retry', () => {
|
describe('authenticatedFetch - network error retry', () => {
|
||||||
it('retries once on transient network error', async () => {
|
it('retries once on transient network error', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
@@ -95,7 +95,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authenticatedFetch — basic auth 401 session refresh', () => {
|
describe('authenticatedFetch - basic auth 401 session refresh', () => {
|
||||||
it('refreshes session and retries on 401 for API requests', async () => {
|
it('refreshes session and retries on 401 for API requests', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' });
|
const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' });
|
||||||
@@ -138,12 +138,12 @@ describe('JMAPClient resilience', () => {
|
|||||||
|
|
||||||
// connect() should throw without trying to refresh session (would cause infinite recursion)
|
// connect() should throw without trying to refresh session (would cause infinite recursion)
|
||||||
await expect(client.connect()).rejects.toThrow('Invalid username or password');
|
await expect(client.connect()).rejects.toThrow('Invalid username or password');
|
||||||
// Only one fetch call — no refresh attempt
|
// Only one fetch call - no refresh attempt
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authenticatedFetch — bearer token refresh', () => {
|
describe('authenticatedFetch - bearer token refresh', () => {
|
||||||
it('refreshes token and retries on 401 for bearer mode', async () => {
|
it('refreshes token and retries on 401 for bearer mode', async () => {
|
||||||
const tokenRefresh = vi.fn().mockResolvedValue('new-token-456');
|
const tokenRefresh = vi.fn().mockResolvedValue('new-token-456');
|
||||||
const session = makeSession();
|
const session = makeSession();
|
||||||
@@ -172,7 +172,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authenticatedFetch — 429 rate limiting', () => {
|
describe('authenticatedFetch - 429 rate limiting', () => {
|
||||||
it('stops sending authenticated requests until the retry window expires', async () => {
|
it('stops sending authenticated requests until the retry window expires', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ describe('JMAPClient resilience', () => {
|
|||||||
// So ping throws, keep-alive catches it, fires false
|
// So ping throws, keep-alive catches it, fires false
|
||||||
// Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds
|
// Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds
|
||||||
fetchSpy
|
fetchSpy
|
||||||
// ping fails — network error, retry also fails
|
// ping fails - network error, retry also fails
|
||||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
// reconnect → connect() → session URL succeeds
|
// reconnect → connect() → session URL succeeds
|
||||||
|
|||||||
@@ -267,13 +267,13 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
||||||
// Reporter's exact scenario: two subfolders with the same name.
|
// Reporter's exact scenario: two subfolders with the same name.
|
||||||
// The dedup uses substring matching and removes non-role folders whose name
|
// The dedup uses substring matching and removes non-role folders whose name
|
||||||
// matches a role folder — even if they're deep in the tree with children.
|
// matches a role folder - even if they're deep in the tree with children.
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
// User-created subfolder also named "Sent" nested under Inbox
|
// User-created subfolder also named "Sent" nested under Inbox
|
||||||
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
||||||
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
|
// Child of the custom "Sent" folder - becomes orphaned if parent is deduped
|
||||||
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
const flat = flattenMailboxTree(tree);
|
const flat = flattenMailboxTree(tree);
|
||||||
const rootIds = tree.map(n => n.id);
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
// sent-custom MUST be kept because it has children — removing it orphans sent-child
|
// sent-custom MUST be kept because it has children - removing it orphans sent-child
|
||||||
const sentCustom = flat.find(n => n.id === 'sent-custom');
|
const sentCustom = flat.find(n => n.id === 'sent-custom');
|
||||||
expect(sentCustom).toBeDefined();
|
expect(sentCustom).toBeDefined();
|
||||||
expect(sentCustom!.depth).toBe(1); // nested under Inbox
|
expect(sentCustom!.depth).toBe(1); // nested under Inbox
|
||||||
@@ -293,7 +293,7 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should keep nested folders when name is substring of a role name', () => {
|
it('should keep nested folders when name is substring of a role name', () => {
|
||||||
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
|
// "Draft" is a substring of "Drafts" - dedup removes it, orphaning children
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
||||||
@@ -343,22 +343,22 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () =>
|
|||||||
|
|
||||||
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
|
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
|
||||||
// Dedup should only remove mailboxes that are BOTH:
|
// Dedup should only remove mailboxes that are BOTH:
|
||||||
// 1. At root level (no parentId) — same structural position as role mailbox
|
// 1. At root level (no parentId) - same structural position as role mailbox
|
||||||
// 2. Name-matching a role mailbox
|
// 2. Name-matching a role mailbox
|
||||||
// Nested mailboxes with matching names should always be kept.
|
// Nested mailboxes with matching names should always be kept.
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
|
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate - OK to remove
|
||||||
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
|
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
|
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested - must keep
|
||||||
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
|
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const flat = flattenMailboxTree(tree);
|
const flat = flattenMailboxTree(tree);
|
||||||
|
|
||||||
// "Sent Mail" at root (no parentId) can be deduped — that's fine
|
// "Sent Mail" at root (no parentId) can be deduped - that's fine
|
||||||
// But "Sent" nested under Projects must be kept
|
// But "Sent" nested under Projects must be kept
|
||||||
const sentNested = flat.find(n => n.id === 'sent-nested');
|
const sentNested = flat.find(n => n.id === 'sent-nested');
|
||||||
expect(sentNested).toBeDefined();
|
expect(sentNested).toBeDefined();
|
||||||
@@ -377,7 +377,7 @@ describe('mailbox orphan behavior (missing parent)', () => {
|
|||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
// 'bookings' is MISSING — simulating truncated JMAP response
|
// 'bookings' is MISSING - simulating truncated JMAP response
|
||||||
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('oauth/discovery', () => {
|
|||||||
expect(consoleSpy).toHaveBeenCalled();
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caches results — second call for same server URL does not re-fetch', async () => {
|
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(VALID_METADATA),
|
json: () => Promise.resolve(VALID_METADATA),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useIdentityStore } from '@/stores/identity-store';
|
|||||||
import { useVacationStore } from '@/stores/vacation-store';
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
import { useSmimeStore } from '@/stores/smime-store';
|
import { useSmimeStore } from '@/stores/smime-store';
|
||||||
|
|
||||||
// Minimal snapshot shapes — we only capture what we need
|
// Minimal snapshot shapes - we only capture what we need
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type StoreSnapshot = Record<string, any>;
|
type StoreSnapshot = Record<string, any>;
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isHashed(envPassword)) {
|
if (isHashed(envPassword)) {
|
||||||
// Already hashed in env — save to file
|
// Already hashed in env - save to file
|
||||||
const data: AdminData = {
|
const data: AdminData = {
|
||||||
passwordHash: envPassword,
|
passwordHash: envPassword,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -129,7 +129,7 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleartext — hash it
|
// Cleartext - hash it
|
||||||
const hash = await hashPassword(envPassword);
|
const hash = await hashPassword(envPassword);
|
||||||
const data: AdminData = {
|
const data: AdminData = {
|
||||||
passwordHash: hash,
|
passwordHash: hash,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function getPathPrefix(locale?: string): string {
|
|||||||
* through unchanged.
|
* through unchanged.
|
||||||
*
|
*
|
||||||
* Server code (route handlers, layout files running at SSR) should keep using
|
* Server code (route handlers, layout files running at SSR) should keep using
|
||||||
* the raw Fetch API — the mount prefix is a browser-only concept.
|
* the raw Fetch API - the mount prefix is a browser-only concept.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* await apiFetch('/api/jmap', { method: 'POST', body })
|
* await apiFetch('/api/jmap', { method: 'POST', body })
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { generateDemoId } from './demo-utils';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory JMAP client for demo mode.
|
* In-memory JMAP client for demo mode.
|
||||||
* All data lives in memory — no network calls, no cookies.
|
* All data lives in memory - no network calls, no cookies.
|
||||||
*/
|
*/
|
||||||
export class DemoJMAPClient implements IJMAPClient {
|
export class DemoJMAPClient implements IJMAPClient {
|
||||||
private data: DemoData;
|
private data: DemoData;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ export function generateDemoId(prefix: string = 'demo'): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate an ISO date string relative to "now".
|
* Generate an ISO date string relative to "now".
|
||||||
* @param daysOffset — whole days from today
|
* @param daysOffset - whole days from today
|
||||||
* @param hoursOffset — additional hours offset (default 0)
|
* @param hoursOffset - additional hours offset (default 0)
|
||||||
* @param minutesOffset — additional minutes offset (default 0)
|
* @param minutesOffset - additional minutes offset (default 0)
|
||||||
*/
|
*/
|
||||||
export function demoDate(daysOffset: number, hoursOffset: number = 0, minutesOffset: number = 0): string {
|
export function demoDate(daysOffset: number, hoursOffset: number = 0, minutesOffset: number = 0): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function createDemoEmails(): Email[] {
|
|||||||
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
|
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
|
||||||
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
|
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
|
||||||
bodyValues: {
|
bodyValues: {
|
||||||
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review — Oct 15\n- Phase 2: Development — Nov 1-30\n- Phase 3: Testing — Dec 1-15\n- Phase 4: Launch — Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
|
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
|
||||||
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review — Oct 15</li><li>Phase 2: Development — Nov 1-30</li><li>Phase 3: Testing — Dec 1-15</li><li>Phase 4: Launch — Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
|
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
|
||||||
},
|
},
|
||||||
messageId: '<q4-timeline-1@example.com>',
|
messageId: '<q4-timeline-1@example.com>',
|
||||||
},
|
},
|
||||||
@@ -83,7 +83,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
|
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
|
||||||
bodyValues: {
|
bodyValues: {
|
||||||
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n— Bob' },
|
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob' },
|
||||||
},
|
},
|
||||||
messageId: '<q4-timeline-2@example.com>',
|
messageId: '<q4-timeline-2@example.com>',
|
||||||
inReplyTo: ['<q4-timeline-1@example.com>'],
|
inReplyTo: ['<q4-timeline-1@example.com>'],
|
||||||
@@ -104,7 +104,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
|
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
|
||||||
bodyValues: {
|
bodyValues: {
|
||||||
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n— Alice' },
|
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice' },
|
||||||
},
|
},
|
||||||
messageId: '<q4-timeline-3@example.com>',
|
messageId: '<q4-timeline-3@example.com>',
|
||||||
inReplyTo: ['<q4-timeline-2@example.com>'],
|
inReplyTo: ['<q4-timeline-2@example.com>'],
|
||||||
@@ -291,7 +291,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
|
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
|
||||||
bodyValues: {
|
bodyValues: {
|
||||||
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n— Bob' },
|
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob' },
|
||||||
},
|
},
|
||||||
messageId: '<project-2@example.com>',
|
messageId: '<project-2@example.com>',
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-19
@@ -104,7 +104,7 @@ const EMAIL_LIST_PROPERTIES = [
|
|||||||
function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown; percentComplete?: unknown }): boolean {
|
function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown; percentComplete?: unknown }): boolean {
|
||||||
const type = obj['@type'];
|
const type = obj['@type'];
|
||||||
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
|
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
|
||||||
// CalDAV-created tasks may lack @type='Task' — detect by task-specific fields
|
// CalDAV-created tasks may lack @type='Task' - detect by task-specific fields
|
||||||
if (type !== 'Event' && (
|
if (type !== 'Event' && (
|
||||||
('progress' in obj && typeof obj.progress === 'string') ||
|
('progress' in obj && typeof obj.progress === 'string') ||
|
||||||
('due' in obj && obj.due != null) ||
|
('due' in obj && obj.due != null) ||
|
||||||
@@ -189,7 +189,7 @@ const CALENDAR_TASK_PROPERTIES = [
|
|||||||
'useDefaultAlerts',
|
'useDefaultAlerts',
|
||||||
'alerts',
|
'alerts',
|
||||||
'relatedTo',
|
'relatedTo',
|
||||||
'percentComplete', // Task-only per RFC 8984 §5.2.4 — used in detection heuristic
|
'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,7 +209,7 @@ function cleanRecurrenceRules(event: Record<string, unknown>): void {
|
|||||||
if (rules === undefined) continue;
|
if (rules === undefined) continue;
|
||||||
delete event[pluralKey];
|
delete event[pluralKey];
|
||||||
if (!Array.isArray(rules)) {
|
if (!Array.isArray(rules)) {
|
||||||
// null means "remove recurrence" — pass through with the correct key
|
// null means "remove recurrence" - pass through with the correct key
|
||||||
event[singularKey] = rules;
|
event[singularKey] = rules;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -398,7 +398,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
response = await fetch(url, { ...init, headers });
|
response = await fetch(url, { ...init, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 429 rate limiting — stop immediately, do not retry
|
// Handle 429 rate limiting - stop immediately, do not retry
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const retryAfterMs = JMAPClient.parseRetryAfter(response);
|
const retryAfterMs = JMAPClient.parseRetryAfter(response);
|
||||||
this.setRateLimited(retryAfterMs);
|
this.setRateLimited(retryAfterMs);
|
||||||
@@ -414,7 +414,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
response = await fetch(url, { ...init, headers: retryHeaders });
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
}
|
}
|
||||||
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
|
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
|
||||||
// JMAP session may have expired — re-establish and retry once
|
// JMAP session may have expired - re-establish and retry once
|
||||||
this.reconnecting = true;
|
this.reconnecting = true;
|
||||||
try {
|
try {
|
||||||
await this.refreshSession();
|
await this.refreshSession();
|
||||||
@@ -422,7 +422,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
response = await fetch(url, { ...init, headers: retryHeaders });
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
} catch {
|
} catch {
|
||||||
// Session refresh failed — if TOTP was used, try re-auth with fresh TOTP
|
// Session refresh failed - if TOTP was used, try re-auth with fresh TOTP
|
||||||
if (this.onTotpRequired && this.basePassword) {
|
if (this.onTotpRequired && this.basePassword) {
|
||||||
try {
|
try {
|
||||||
const newTotp = await this.onTotpRequired();
|
const newTotp = await this.onTotpRequired();
|
||||||
@@ -434,7 +434,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
response = await fetch(url, { ...init, headers: retryHeaders });
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// TOTP re-auth also failed — return original 401
|
// TOTP re-auth also failed - return original 401
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -697,7 +697,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (rawMailboxes.length >= maxObjects) {
|
if (rawMailboxes.length >= maxObjects) {
|
||||||
debug.warn('jmap',
|
debug.warn('jmap',
|
||||||
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||||||
`Some mailboxes may be missing — nested folders could appear orphaned at root level.`
|
`Some mailboxes may be missing - nested folders could appear orphaned at root level.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,7 +850,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
||||||
const emails = (getResponse.list || []) as Email[];
|
const emails = (getResponse.list || []) as Email[];
|
||||||
// Sort client-side as safety net — some servers may not honour
|
// Sort client-side as safety net - some servers may not honour
|
||||||
// the query sort for large mailboxes without additional filters.
|
// the query sort for large mailboxes without additional filters.
|
||||||
emails.sort((a: Email, b: Email) =>
|
emails.sort((a: Email, b: Email) =>
|
||||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
@@ -2814,7 +2814,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
for (const [id, account] of Object.entries(this.accounts)) {
|
for (const [id, account] of Object.entries(this.accounts)) {
|
||||||
if (id === primaryId) continue;
|
if (id === primaryId) continue;
|
||||||
// Include accounts that either advertise calendar capability
|
// Include accounts that either advertise calendar capability
|
||||||
// or are non-personal (shared/group) accounts — Stalwart doesn't
|
// or are non-personal (shared/group) accounts - Stalwart doesn't
|
||||||
// always advertise capabilities on group accounts even when they
|
// always advertise capabilities on group accounts even when they
|
||||||
// have calendar resources.
|
// have calendar resources.
|
||||||
if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || !account.isPersonal) {
|
if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || !account.isPersonal) {
|
||||||
@@ -2830,7 +2830,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
for (const [id, account] of Object.entries(this.accounts)) {
|
for (const [id, account] of Object.entries(this.accounts)) {
|
||||||
if (id === primaryId) continue;
|
if (id === primaryId) continue;
|
||||||
// Include accounts that either advertise contacts capability
|
// Include accounts that either advertise contacts capability
|
||||||
// or are non-personal (shared/group) accounts — Stalwart doesn't
|
// or are non-personal (shared/group) accounts - Stalwart doesn't
|
||||||
// always advertise capabilities on group accounts even when they
|
// always advertise capabilities on group accounts even when they
|
||||||
// have contact resources.
|
// have contact resources.
|
||||||
if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || !account.isPersonal) {
|
if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || !account.isPersonal) {
|
||||||
@@ -3976,7 +3976,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
|
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
|
||||||
// set to something other than 'Event'. Detect them by the presence of
|
// set to something other than 'Event'. Detect them by the presence of
|
||||||
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
|
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
|
||||||
// defines as Task-only — a VEVENT will never include them in the response.
|
// defines as Task-only - a VEVENT will never include them in the response.
|
||||||
// We check for key presence (even if null) because Stalwart may return null
|
// We check for key presence (even if null) because Stalwart may return null
|
||||||
// instead of the RFC defaults (e.g. progress default is "needs-action").
|
// instead of the RFC defaults (e.g. progress default is "needs-action").
|
||||||
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
|
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
|
||||||
@@ -4060,7 +4060,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (!createdId) {
|
if (!createdId) {
|
||||||
debug.warn('tasks', 'CalendarTask/create no id in server response');
|
debug.warn('tasks', 'CalendarTask/create no id in server response');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
throw new Error("Failed to create task — no id returned");
|
throw new Error("Failed to create task - no id returned");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch back with task-specific properties
|
// Fetch back with task-specific properties
|
||||||
@@ -4230,7 +4230,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
// Stalwart requires a blobId even for directories — upload an empty blob
|
// Stalwart requires a blobId even for directories - upload an empty blob
|
||||||
const emptyBlob = new File([], name, { type: 'application/x-directory' });
|
const emptyBlob = new File([], name, { type: 'application/x-directory' });
|
||||||
const { blobId } = await this.uploadBlob(emptyBlob);
|
const { blobId } = await this.uploadBlob(emptyBlob);
|
||||||
|
|
||||||
@@ -4489,7 +4489,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
this.stopSSEPingMonitor();
|
this.stopSSEPingMonitor();
|
||||||
|
|
||||||
// Stream ended — reconnect unless we were intentionally closed
|
// Stream ended - reconnect unless we were intentionally closed
|
||||||
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
|
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
|
||||||
this.scheduleSSEReconnect();
|
this.scheduleSSEReconnect();
|
||||||
}
|
}
|
||||||
@@ -4512,7 +4512,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const change = JSON.parse(dataLines.join('\n')) as StateChange;
|
const change = JSON.parse(dataLines.join('\n')) as StateChange;
|
||||||
this.stateChangeCallback?.(change);
|
this.stateChangeCallback?.(change);
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed SSE data — ignore
|
// Malformed SSE data - ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4671,7 +4671,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.stopSSEPingMonitor();
|
this.stopSSEPingMonitor();
|
||||||
this.ssePingTimer = setInterval(() => {
|
this.ssePingTimer = setInterval(() => {
|
||||||
if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) {
|
if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) {
|
||||||
// SSE connection is stale — abort and reconnect
|
// SSE connection is stale - abort and reconnect
|
||||||
this.stopSSEPingMonitor();
|
this.stopSSEPingMonitor();
|
||||||
if (this.sseAbortController) {
|
if (this.sseAbortController) {
|
||||||
this.sseAbortController.abort();
|
this.sseAbortController.abort();
|
||||||
@@ -4693,7 +4693,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
this.visibilityHandler = () => {
|
this.visibilityHandler = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
// Tab became visible — immediately check for state changes
|
// Tab became visible - immediately check for state changes
|
||||||
this.checkForStateChanges();
|
this.checkForStateChanges();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -4702,7 +4702,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
this.onlineHandler = () => {
|
this.onlineHandler = () => {
|
||||||
// Network reconnected — reconnect SSE or force a poll
|
// Network reconnected - reconnect SSE or force a poll
|
||||||
const eventSourceUrl = this.getEventSourceUrl();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
if (eventSourceUrl && !this.sseAbortController) {
|
if (eventSourceUrl && !this.sseAbortController) {
|
||||||
this.connectSSE(eventSourceUrl);
|
this.connectSSE(eventSourceUrl);
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ export interface Email {
|
|||||||
// S/MIME support
|
// S/MIME support
|
||||||
blobId?: string;
|
blobId?: string;
|
||||||
bodyStructure?: EmailBodyPart;
|
bodyStructure?: EmailBodyPart;
|
||||||
// Unified mailbox support — set when displaying emails from multiple accounts
|
// Unified mailbox support - set when displaying emails from multiple accounts
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountLabel?: string;
|
accountLabel?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -112,7 +112,7 @@ function createPluginLogger(pluginId: string) {
|
|||||||
|
|
||||||
export interface PluginAPI {
|
export interface PluginAPI {
|
||||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||||
/** Localisation API — register translations and call t() to get strings */
|
/** Localisation API - register translations and call t() to get strings */
|
||||||
i18n: PluginI18n;
|
i18n: PluginI18n;
|
||||||
ui: {
|
ui: {
|
||||||
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
||||||
@@ -154,7 +154,7 @@ export interface PluginHooksAPI {
|
|||||||
onEmailClose: (handler: () => void) => Disposable;
|
onEmailClose: (handler: () => void) => Disposable;
|
||||||
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
|
/** Intercept - receives ComposeOptions, may mutate fields, return false to cancel */
|
||||||
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
|
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||||
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -184,7 +184,7 @@ export interface PluginHooksAPI {
|
|||||||
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
|
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
||||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||||
// Calendar
|
// Calendar
|
||||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -228,7 +228,7 @@ export interface PluginHooksAPI {
|
|||||||
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
|
/** Intercept - receives { file: FileResourceView, newName: string }, return false to cancel */
|
||||||
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
|
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||||
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -331,7 +331,7 @@ export interface PluginHooksAPI {
|
|||||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
// Avatar
|
// Avatar
|
||||||
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
// Render — transform hook for email list row badges
|
// Render - transform hook for email list row badges
|
||||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -1,4 +1,4 @@
|
|||||||
// Plugin Hook Bus — event bus system for plugin lifecycle hooks
|
// Plugin Hook Bus - event bus system for plugin lifecycle hooks
|
||||||
|
|
||||||
import type { Disposable } from './plugin-types';
|
import type { Disposable } from './plugin-types';
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export class HookBus<T extends (...args: any[]) => any> {
|
|||||||
return this.handlers.length;
|
return this.handlers.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire all handlers (observer pattern — no return values used) */
|
/** Fire all handlers (observer pattern - no return values used) */
|
||||||
async emit(...args: Parameters<T>): Promise<void> {
|
async emit(...args: Parameters<T>): Promise<void> {
|
||||||
for (const { pluginId, handler } of this.handlers) {
|
for (const { pluginId, handler } of this.handlers) {
|
||||||
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
||||||
@@ -132,7 +132,7 @@ export class HookBus<T extends (...args: any[]) => any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire handlers as interceptors — any returning false cancels the operation */
|
/** Fire handlers as interceptors - any returning false cancels the operation */
|
||||||
async intercept(...args: Parameters<T>): Promise<boolean> {
|
async intercept(...args: Parameters<T>): Promise<boolean> {
|
||||||
for (const { pluginId, handler } of this.handlers) {
|
for (const { pluginId, handler } of this.handlers) {
|
||||||
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
||||||
@@ -146,7 +146,7 @@ export class HookBus<T extends (...args: any[]) => any> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire handlers as transforms — each receives the output of the previous */
|
/** Fire handlers as transforms - each receives the output of the previous */
|
||||||
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
|
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
|
||||||
let value = initial;
|
let value = initial;
|
||||||
for (const { pluginId, handler } of this.handlers) {
|
for (const { pluginId, handler } of this.handlers) {
|
||||||
@@ -172,7 +172,7 @@ export const emailHooks = {
|
|||||||
onEmailClose: new HookBus(),
|
onEmailClose: new HookBus(),
|
||||||
onEmailContentRender: new HookBus(),
|
onEmailContentRender: new HookBus(),
|
||||||
onThreadExpand: new HookBus(),
|
onThreadExpand: new HookBus(),
|
||||||
// Intercept hook — fires before the composer opens.
|
// Intercept hook - fires before the composer opens.
|
||||||
// Handlers receive ComposeOptions and may mutate fields in place.
|
// Handlers receive ComposeOptions and may mutate fields in place.
|
||||||
// Return false to cancel opening the composer.
|
// Return false to cancel opening the composer.
|
||||||
onBeforeCompose: new HookBus(),
|
onBeforeCompose: new HookBus(),
|
||||||
@@ -204,7 +204,7 @@ export const emailHooks = {
|
|||||||
onNewEmailReceived: new HookBus(),
|
onNewEmailReceived: new HookBus(),
|
||||||
onPushConnectionChange: new HookBus(),
|
onPushConnectionChange: new HookBus(),
|
||||||
onQuotaChange: new HookBus(),
|
onQuotaChange: new HookBus(),
|
||||||
// Intercept hook — fired when a mailto: link is clicked.
|
// Intercept hook - fired when a mailto: link is clicked.
|
||||||
// Return false to prevent the browser from opening the system mail client.
|
// Return false to prevent the browser from opening the system mail client.
|
||||||
onMailtoIntercept: new HookBus(),
|
onMailtoIntercept: new HookBus(),
|
||||||
};
|
};
|
||||||
@@ -261,7 +261,7 @@ export const fileHooks = {
|
|||||||
onDirectoryCreate: new HookBus(),
|
onDirectoryCreate: new HookBus(),
|
||||||
onBeforeFileDelete: new HookBus(),
|
onBeforeFileDelete: new HookBus(),
|
||||||
onAfterFileDelete: new HookBus(),
|
onAfterFileDelete: new HookBus(),
|
||||||
// Intercept hook — fires before a file is renamed.
|
// Intercept hook - fires before a file is renamed.
|
||||||
// Receives { file: FileResourceView, newName: string }.
|
// Receives { file: FileResourceView, newName: string }.
|
||||||
// Return false to cancel the rename.
|
// Return false to cancel the rename.
|
||||||
onBeforeFileRename: new HookBus(),
|
onBeforeFileRename: new HookBus(),
|
||||||
@@ -423,7 +423,7 @@ export const avatarHooks = {
|
|||||||
|
|
||||||
// §7.22 Render Hooks
|
// §7.22 Render Hooks
|
||||||
export const renderHooks = {
|
export const renderHooks = {
|
||||||
// Transform hook — runs for each visible email list row.
|
// Transform hook - runs for each visible email list row.
|
||||||
// Initial value: EmailListBadge[] (always starts as [])
|
// Initial value: EmailListBadge[] (always starts as [])
|
||||||
// Second argument: { emailId: string; email: EmailReadView }
|
// Second argument: { emailId: string; email: EmailReadView }
|
||||||
// Handlers return a new (or extended) badges array.
|
// Handlers return a new (or extended) badges array.
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// Plugin i18n registry — manages per-plugin translation tables
|
// Plugin i18n registry - manages per-plugin translation tables
|
||||||
//
|
//
|
||||||
// Each plugin gets its own namespace keyed by:
|
// Each plugin gets its own namespace keyed by:
|
||||||
// pluginId → locale → { messageKey → translated string }
|
// pluginId → locale → { messageKey → translated string }
|
||||||
@@ -105,7 +105,7 @@ export function createPluginI18n(pluginId: string) {
|
|||||||
t(key: string, params?: Record<string, string | number>): string {
|
t(key: string, params?: Record<string, string | number>): string {
|
||||||
const template = resolve(pluginId, key);
|
const template = resolve(pluginId, key);
|
||||||
if (template !== undefined) return interpolate(template, params);
|
if (template !== undefined) return interpolate(template, params);
|
||||||
return key; // never throw — just return the key
|
return key; // never throw - just return the key
|
||||||
},
|
},
|
||||||
|
|
||||||
/** The current app locale (e.g. "en", "de", "fr") */
|
/** The current app locale (e.g. "en", "de", "fr") */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
// Plugin Loader - loads and activates plugins via blob URL dynamic import
|
||||||
|
|
||||||
import type { InstalledPlugin, Disposable } from './plugin-types';
|
import type { InstalledPlugin, Disposable } from './plugin-types';
|
||||||
import { pluginStorage } from './plugin-storage';
|
import { pluginStorage } from './plugin-storage';
|
||||||
|
|||||||
+2
-2
@@ -195,7 +195,7 @@ export interface KeyboardShortcut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Read-Only View Types ────────────────────────────────────
|
// ─── Read-Only View Types ────────────────────────────────────
|
||||||
// Projected views exposed to plugins — no direct store references
|
// Projected views exposed to plugins - no direct store references
|
||||||
|
|
||||||
export interface EmailReadView {
|
export interface EmailReadView {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -415,7 +415,7 @@ export interface ComposeOptions {
|
|||||||
* A small visual indicator injected into an email list row via onEmailListItemRender.
|
* A small visual indicator injected into an email list row via onEmailListItemRender.
|
||||||
*/
|
*/
|
||||||
export interface EmailListBadge {
|
export interface EmailListBadge {
|
||||||
/** Stable unique key within the plugin — used as React key */
|
/** Stable unique key within the plugin - used as React key */
|
||||||
key: string;
|
key: string;
|
||||||
/** Short label text displayed in the badge */
|
/** Short label text displayed in the badge */
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function checkJSSecurity(code: string): string[] {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
if (pattern.test(code)) {
|
if (pattern.test(code)) {
|
||||||
warnings.push(`Contains ${label} — review for security`);
|
warnings.push(`Contains ${label} - review for security`);
|
||||||
}
|
}
|
||||||
pattern.lastIndex = 0; // reset regex
|
pattern.lastIndex = 0; // reset regex
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
|||||||
* store can use it for mutations.
|
* store can use it for mutations.
|
||||||
*
|
*
|
||||||
* Non-recurring events are returned as-is. For recurring events the master is
|
* Non-recurring events are returned as-is. For recurring events the master is
|
||||||
* **not** returned — only expanded instances within the range.
|
* **not** returned - only expanded instances within the range.
|
||||||
*/
|
*/
|
||||||
export function expandRecurringEvents(
|
export function expandRecurringEvents(
|
||||||
events: CalendarEvent[],
|
events: CalendarEvent[],
|
||||||
@@ -46,7 +46,7 @@ export function expandRecurringEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
// Skip override instances returned by the server — they belong to a
|
// Skip override instances returned by the server - they belong to a
|
||||||
// master recurring event and are already handled via recurrenceOverrides.
|
// master recurring event and are already handled via recurrenceOverrides.
|
||||||
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) {
|
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -165,7 +165,7 @@ function createOccurrence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// §3.3.3.1 — Implicit byX property addition
|
// §3.3.3.1 - Implicit byX property addition
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function addImplicitByX(
|
function addImplicitByX(
|
||||||
rule: CalendarRecurrenceRule,
|
rule: CalendarRecurrenceRule,
|
||||||
@@ -211,7 +211,7 @@ function addImplicitByX(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// §3.3.3.1 — Generate occurrence dates
|
// §3.3.3.1 - Generate occurrence dates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function generateDates(
|
function generateDates(
|
||||||
eventStart: Date,
|
eventStart: Date,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('external rule preservation (issue #201)', () => {
|
describe('external rule preservation (issue #201)', () => {
|
||||||
describe('parser — external rule recognition', () => {
|
describe('parser - external rule recognition', () => {
|
||||||
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
|
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
|
||||||
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
|
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
|
||||||
const result = parseScript(script);
|
const result = parseScript(script);
|
||||||
@@ -84,7 +84,7 @@ describe('external rule preservation (issue #201)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parser — mixed Bulwark + external', () => {
|
describe('parser - mixed Bulwark + external', () => {
|
||||||
it('returns Bulwark rules from metadata and external rules from the rest', () => {
|
it('returns Bulwark rules from metadata and external rules from the rest', () => {
|
||||||
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
|
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
|
||||||
const bulwarkScript = generateScript(bulwark);
|
const bulwarkScript = generateScript(bulwark);
|
||||||
@@ -113,7 +113,7 @@ describe('external rule preservation (issue #201)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generator — external splice', () => {
|
describe('generator - external splice', () => {
|
||||||
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
|
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
|
||||||
const externalRule: FilterRule = {
|
const externalRule: FilterRule = {
|
||||||
id: 'ext-0',
|
id: 'ext-0',
|
||||||
@@ -253,7 +253,7 @@ describe('external rule preservation (issue #201)', () => {
|
|||||||
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
|
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
|
||||||
expect(externalBefore).toHaveLength(1);
|
expect(externalBefore).toHaveLength(1);
|
||||||
|
|
||||||
// Simulate a user edit — update the Bulwark rule name
|
// Simulate a user edit - update the Bulwark rule name
|
||||||
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
|
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
|
||||||
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
|
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
|
||||||
const reparsed = parseScript(regenerated);
|
const reparsed = parseScript(regenerated);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.c
|
|||||||
|
|
||||||
# --- External rules (managed outside Bulwark) ---
|
# --- External rules (managed outside Bulwark) ---
|
||||||
|
|
||||||
# rule:[Finance — auto-file invoices]
|
# rule:[Finance - auto-file invoices]
|
||||||
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
|
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
|
||||||
fileinto :copy "Finance/Invoices";
|
fileinto :copy "Finance/Invoices";
|
||||||
keep;
|
keep;
|
||||||
|
|||||||
+4
-4
@@ -233,7 +233,7 @@ function extractRequireTokens(stmt: string): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the last contiguous block of comments immediately preceding a
|
* Extract the last contiguous block of comments immediately preceding a
|
||||||
* statement — comments separated from the statement by a blank line are not
|
* statement - comments separated from the statement by a blank line are not
|
||||||
* considered its leading commentary (they likely belong to the previous
|
* considered its leading commentary (they likely belong to the previous
|
||||||
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
|
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
|
||||||
*/
|
*/
|
||||||
@@ -341,7 +341,7 @@ function parseAtom(raw: string): FilterCondition | null {
|
|||||||
} else if (tag === 'is') {
|
} else if (tag === 'is') {
|
||||||
comparator = negated ? 'not_is' : 'is';
|
comparator = negated ? 'not_is' : 'is';
|
||||||
} else {
|
} else {
|
||||||
// :matches — distinguish starts_with / ends_with / matches
|
// :matches - distinguish starts_with / ends_with / matches
|
||||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||||
comparator = 'starts_with';
|
comparator = 'starts_with';
|
||||||
@@ -591,7 +591,7 @@ export function parseScript(content: string): ParseResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No metadata — check vacation-only first
|
// No metadata - check vacation-only first
|
||||||
const vacationOnly = detectVacationOnlyScript(content);
|
const vacationOnly = detectVacationOnlyScript(content);
|
||||||
if (vacationOnly) return vacationOnly;
|
if (vacationOnly) return vacationOnly;
|
||||||
|
|
||||||
@@ -599,7 +599,7 @@ export function parseScript(content: string): ParseResult {
|
|||||||
const external = parseExternalRules(content, 'ext');
|
const external = parseExternalRules(content, 'ext');
|
||||||
|
|
||||||
if (!external.hasContent) {
|
if (!external.hasContent) {
|
||||||
// Entirely empty or whitespace/comments only — treat as empty, editable.
|
// Entirely empty or whitespace/comments only - treat as empty, editable.
|
||||||
return { rules: [], isOpaque: false, externalRequires: [] };
|
return { rules: [], isOpaque: false, externalRequires: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
|
|||||||
names = gn.names;
|
names = gn.names;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed SAN — skip gracefully
|
// Malformed SAN - skip gracefully
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import * as pkijs from 'pkijs';
|
|||||||
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
||||||
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
||||||
// expose the type alias, so we import the module dynamically and cast.
|
// expose the type alias, so we import the module dynamically and cast.
|
||||||
// Import the ES module build directly — the package's "browser" field points
|
// Import the ES module build directly - the package's "browser" field points
|
||||||
// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.).
|
// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.).
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const liner = require('webcrypto-liner/build/index.es.js') as {
|
const liner = require('webcrypto-liner/build/index.es.js') as {
|
||||||
@@ -55,7 +55,7 @@ function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PKCS#12 key derivation — RFC 7292, Appendix B.
|
* PKCS#12 key derivation - RFC 7292, Appendix B.
|
||||||
*
|
*
|
||||||
* @param password BMP-encoded password (with trailing 0x00 0x00)
|
* @param password BMP-encoded password (with trailing 0x00 0x00)
|
||||||
* @param salt raw salt bytes
|
* @param salt raw salt bytes
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ function cmsToBase64Blob(data: Blob | ArrayBuffer | Uint8Array): Blob {
|
|||||||
} else if (data instanceof ArrayBuffer) {
|
} else if (data instanceof ArrayBuffer) {
|
||||||
bytes = new Uint8Array(data);
|
bytes = new Uint8Array(data);
|
||||||
} else {
|
} else {
|
||||||
// Blob — we need sync; caller should have converted. Fallback to empty.
|
// Blob - we need sync; caller should have converted. Fallback to empty.
|
||||||
bytes = new Uint8Array(0);
|
bytes = new Uint8Array(0);
|
||||||
}
|
}
|
||||||
const b64 = base64Encode(bytes.buffer as ArrayBuffer);
|
const b64 = base64Encode(bytes.buffer as ArrayBuffer);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function exportPkcs12(
|
|||||||
parsedValue: {
|
parsedValue: {
|
||||||
safeContents: [
|
safeContents: [
|
||||||
{
|
{
|
||||||
privacyMode: 0, // no extra encryption — key bag is already shrouded
|
privacyMode: 0, // no extra encryption - key bag is already shrouded
|
||||||
value: new pkijs.SafeContents({
|
value: new pkijs.SafeContents({
|
||||||
safeBags: [keyBagSafe],
|
safeBags: [keyBagSafe],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function importPkcs12(
|
|||||||
cert = certBag.parsedValue;
|
cert = certBag.parsedValue;
|
||||||
der = cert.toSchema(true).toBER(false);
|
der = cert.toSchema(true).toBER(false);
|
||||||
} else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) {
|
} else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) {
|
||||||
// x509Certificate — extract DER from the OCTET STRING
|
// x509Certificate - extract DER from the OCTET STRING
|
||||||
const certDerBytes = (certBag.certValue as asn1js.OctetString).valueBlock.valueHexView;
|
const certDerBytes = (certBag.certValue as asn1js.OctetString).valueBlock.valueHexView;
|
||||||
const certAsn1 = asn1js.fromBER(certDerBytes);
|
const certAsn1 = asn1js.fromBER(certDerBytes);
|
||||||
if (certAsn1.offset !== -1) {
|
if (certAsn1.offset !== -1) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
continue; // Key exists but isn't unlocked - skip, caller should unlock first
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -170,7 +170,7 @@ export function normalizeCmsBytes(raw: ArrayBuffer): ArrayBuffer {
|
|||||||
|
|
||||||
const bytes = new Uint8Array(raw);
|
const bytes = new Uint8Array(raw);
|
||||||
|
|
||||||
// Already valid DER — starts with ASN.1 SEQUENCE tag
|
// Already valid DER - starts with ASN.1 SEQUENCE tag
|
||||||
if (bytes[0] === 0x30) {
|
if (bytes[0] === 0x30) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -272,19 +272,19 @@ export function normalizeCmsBytes(raw: ArrayBuffer): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not decodable — return original bytes
|
// Not decodable - return original bytes
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
||||||
const asn1 = asn1js.fromBER(der);
|
const asn1 = asn1js.fromBER(der);
|
||||||
if (asn1.offset === -1) {
|
if (asn1.offset === -1) {
|
||||||
throw new Error('Invalid ASN.1 data — cannot parse CMS envelope');
|
throw new Error('Invalid ASN.1 data - cannot parse CMS envelope');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return new pkijs.ContentInfo({ schema: asn1.result });
|
return new pkijs.ContentInfo({ schema: asn1.result });
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid ASN.1 data — cannot parse CMS envelope');
|
throw new Error('Invalid ASN.1 data - cannot parse CMS envelope');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function detectSmime(
|
|||||||
supported: true,
|
supported: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Generic pkcs7-mime without explicit smime-type — try bodyStructure
|
// Generic pkcs7-mime without explicit smime-type - try bodyStructure
|
||||||
const part = findCmsPart(bodyStructure, null);
|
const part = findCmsPart(bodyStructure, null);
|
||||||
if (part) {
|
if (part) {
|
||||||
const partType = inferSmimeType(part);
|
const partType = inferSmimeType(part);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { parseCertificateDer } from './certificate-utils';
|
|||||||
* Produce an opaque CMS SignedData wrapping the given MIME content.
|
* Produce an opaque CMS SignedData wrapping the given MIME content.
|
||||||
*
|
*
|
||||||
* Content type: application/pkcs7-mime; smime-type=signed-data
|
* Content type: application/pkcs7-mime; smime-type=signed-data
|
||||||
* This is the "opaque" form — the content is embedded inside the CMS structure.
|
* This is the "opaque" form - the content is embedded inside the CMS structure.
|
||||||
*/
|
*/
|
||||||
export async function smimeSign(
|
export async function smimeSign(
|
||||||
mimeBytes: Uint8Array,
|
mimeBytes: Uint8Array,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export async function smimeVerify(
|
|||||||
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
||||||
const asn1 = asn1js.fromBER(der);
|
const asn1 = asn1js.fromBER(der);
|
||||||
if (asn1.offset === -1) {
|
if (asn1.offset === -1) {
|
||||||
throw new Error('Invalid ASN.1 data — cannot parse CMS structure');
|
throw new Error('Invalid ASN.1 data - cannot parse CMS structure');
|
||||||
}
|
}
|
||||||
return new pkijs.ContentInfo({ schema: asn1.result });
|
return new pkijs.ContentInfo({ schema: asn1.result });
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ export function validateThemeSelectors(css: string): string[] {
|
|||||||
// Inside @media blocks, also allow :root and .dark
|
// Inside @media blocks, also allow :root and .dark
|
||||||
if (selector === ':root' || selector === '.dark') continue;
|
if (selector === ':root' || selector === '.dark') continue;
|
||||||
|
|
||||||
warnings.push(`Non-standard selector "${selector}" — themes should only use :root and .dark`);
|
warnings.push(`Non-standard selector "${selector}" - themes should only use :root and .dark`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return warnings;
|
return warnings;
|
||||||
|
|||||||
+4
-4
@@ -125,7 +125,7 @@ function readMAPIFixedValue(r: BinaryReader, propType: number): Uint8Array | num
|
|||||||
case PT_CLSID:
|
case PT_CLSID:
|
||||||
return r.readBytes(16);
|
return r.readBytes(16);
|
||||||
default:
|
default:
|
||||||
// Unknown/unsupported type — try to read as fixed 4 bytes
|
// Unknown/unsupported type - try to read as fixed 4 bytes
|
||||||
if (r.remaining >= 4) {
|
if (r.remaining >= 4) {
|
||||||
return r.readBytes(4);
|
return r.readBytes(4);
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attrCount++;
|
attrCount++;
|
||||||
|
|
||||||
if (attrLen > r.remaining - 2) {
|
if (attrLen > r.remaining - 2) {
|
||||||
debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
debug.warn('email', 'Attribute #' + attrCount + ': truncated data - need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
}
|
}
|
||||||
} else if (level === LVL_ATTACHMENT) {
|
} else if (level === LVL_ATTACHMENT) {
|
||||||
if (attrID === attAttachRenddata) {
|
if (attrID === attAttachRenddata) {
|
||||||
// Start of a new attachment — flush previous
|
// Start of a new attachment - flush previous
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
@@ -375,7 +375,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
debug.log('email', 'TNEF parsing complete - body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||||
if (result.attachments.length > 0) {
|
if (result.attachments.length > 0) {
|
||||||
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
|
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never deduplicate nested mailboxes — only root-level folders can be
|
// Never deduplicate nested mailboxes - only root-level folders can be
|
||||||
// duplicates of role-based mailboxes. Removing a nested folder that happens
|
// duplicates of role-based mailboxes. Removing a nested folder that happens
|
||||||
// to share a name with a role folder (e.g. a subfolder named "Sent") would
|
// to share a name with a role folder (e.g. a subfolder named "Sent") would
|
||||||
// orphan its children to root level. (GitHub #118)
|
// orphan its children to root level. (GitHub #118)
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Impressum",
|
"imprint": "Impressum",
|
||||||
"privacy_policy": "Datenschutz",
|
"privacy_policy": "Datenschutz",
|
||||||
"try_demo": "Demo testen",
|
"try_demo": "Demo testen",
|
||||||
"demo_description": "Erkunden Sie mit Beispieldaten — kein Konto nötig",
|
"demo_description": "Erkunden Sie mit Beispieldaten - kein Konto nötig",
|
||||||
"demo_launching": "Demo wird gestartet...",
|
"demo_launching": "Demo wird gestartet...",
|
||||||
"demo_login_button": "Demo starten",
|
"demo_login_button": "Demo starten",
|
||||||
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
|
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
|
||||||
"demo_no_signup": "Keine Registrierung nötig — erkunden Sie frei mit Beispieldaten",
|
"demo_no_signup": "Keine Registrierung nötig - erkunden Sie frei mit Beispieldaten",
|
||||||
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authentifizierung fehlgeschlagen",
|
"title": "Authentifizierung fehlgeschlagen",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "Das Enddatum muss nach dem Startdatum liegen",
|
"end_before_start": "Das Enddatum muss nach dem Startdatum liegen",
|
||||||
"start_in_past": "Das Startdatum liegt in der Vergangenheit",
|
"start_in_past": "Das Startdatum liegt in der Vergangenheit",
|
||||||
"empty_body": "Der Nachrichtentext ist leer — Empfänger erhalten eine leere Antwort"
|
"empty_body": "Der Nachrichtentext ist leer - Empfänger erhalten eine leere Antwort"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Willkommen bei Bulwark Mail",
|
"title": "Willkommen bei Bulwark Mail",
|
||||||
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client — direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
|
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client - direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
|
||||||
"feature_email": "E-Mails lesen & verfassen",
|
"feature_email": "E-Mails lesen & verfassen",
|
||||||
"feature_organize": "Tags, Sterne & Ordner",
|
"feature_organize": "Tags, Sterne & Ordner",
|
||||||
"feature_shortcuts": "Tastenkürzel",
|
"feature_shortcuts": "Tastenkürzel",
|
||||||
@@ -2533,7 +2533,7 @@
|
|||||||
"create_event_title": "Ereignis erstellen",
|
"create_event_title": "Ereignis erstellen",
|
||||||
"create_event_desc": "Klicken Sie auf diese Schaltfläche, um ein neues Kalenderereignis zu erstellen. Sie können Titel, Datum, Uhrzeit und Teilnehmer festlegen.",
|
"create_event_desc": "Klicken Sie auf diese Schaltfläche, um ein neues Kalenderereignis zu erstellen. Sie können Titel, Datum, Uhrzeit und Teilnehmer festlegen.",
|
||||||
"event_modal_title": "Ereignisdetails",
|
"event_modal_title": "Ereignisdetails",
|
||||||
"event_modal_desc": "Hier ist das Ereignisformular. Geben Sie den Titel ein, wählen Sie Datum und Uhrzeit, fügen Sie einen Ort oder Teilnehmer hinzu. Klicken Sie auf Speichern, wenn Sie fertig sind — oder schließen Sie es und fahren Sie fort.",
|
"event_modal_desc": "Hier ist das Ereignisformular. Geben Sie den Titel ein, wählen Sie Datum und Uhrzeit, fügen Sie einen Ort oder Teilnehmer hinzu. Klicken Sie auf Speichern, wenn Sie fertig sind - oder schließen Sie es und fahren Sie fort.",
|
||||||
"contacts_list_title": "Ihre Kontakte",
|
"contacts_list_title": "Ihre Kontakte",
|
||||||
"contacts_list_desc": "Hier sind Ihre Kontakte. Klicken Sie auf einen Kontakt, um Details zu sehen. Sie können neue Kontakte erstellen oder vCards importieren.",
|
"contacts_list_desc": "Hier sind Ihre Kontakte. Klicken Sie auf einen Kontakt, um Details zu sehen. Sie können neue Kontakte erstellen oder vCards importieren.",
|
||||||
"files_title": "Dateispeicher",
|
"files_title": "Dateispeicher",
|
||||||
@@ -2541,7 +2541,7 @@
|
|||||||
"settings_tabs_desc": "Hier finden Sie alle Einstellungskategorien. Passen Sie das Erscheinungsbild an, verwalten Sie Identitäten, richten Sie E-Mail-Filter ein, konfigurieren Sie Ihren Kalender und vieles mehr.",
|
"settings_tabs_desc": "Hier finden Sie alle Einstellungskategorien. Passen Sie das Erscheinungsbild an, verwalten Sie Identitäten, richten Sie E-Mail-Filter ein, konfigurieren Sie Ihren Kalender und vieles mehr.",
|
||||||
"files_desc": "Ihr Dateibrowser zum Hochladen, Organisieren und Teilen von Dateien.",
|
"files_desc": "Ihr Dateibrowser zum Hochladen, Organisieren und Teilen von Dateien.",
|
||||||
"demo_banner_title": "Demo-Steuerung",
|
"demo_banner_title": "Demo-Steuerung",
|
||||||
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
|
"demo_banner_desc": "Sie sind im Demo-Modus - alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
|
||||||
"quota_title": "Speichernutzung",
|
"quota_title": "Speichernutzung",
|
||||||
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
|
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Imprint",
|
"imprint": "Imprint",
|
||||||
"privacy_policy": "Privacy Policy",
|
"privacy_policy": "Privacy Policy",
|
||||||
"try_demo": "Try Demo",
|
"try_demo": "Try Demo",
|
||||||
"demo_description": "Explore with sample data — no account needed",
|
"demo_description": "Explore with sample data - no account needed",
|
||||||
"demo_launching": "Launching demo...",
|
"demo_launching": "Launching demo...",
|
||||||
"demo_login_button": "Launch Demo",
|
"demo_login_button": "Launch Demo",
|
||||||
"demo_tagline": "Experience a full-featured email client. No account required.",
|
"demo_tagline": "Experience a full-featured email client. No account required.",
|
||||||
"demo_no_signup": "No signup needed — explore freely with sample data",
|
"demo_no_signup": "No signup needed - explore freely with sample data",
|
||||||
"oauth_completing": "Completing sign in...",
|
"oauth_completing": "Completing sign in...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authentication Failed",
|
"title": "Authentication Failed",
|
||||||
@@ -928,7 +928,7 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"invalid_email": "Please enter a valid email address",
|
"invalid_email": "Please enter a valid email address",
|
||||||
"already_added": "This sender is already trusted",
|
"already_added": "This sender is already trusted",
|
||||||
"save_error": "Failed to save — check the Contacts debug log for details",
|
"save_error": "Failed to save - check the Contacts debug log for details",
|
||||||
"use_address_book_label": "Sync with address book",
|
"use_address_book_label": "Sync with address book",
|
||||||
"use_address_book_description": "Store trusted senders in a dedicated \"Trusted Senders\" address book so they sync across all your devices"
|
"use_address_book_description": "Store trusted senders in a dedicated \"Trusted Senders\" address book so they sync across all your devices"
|
||||||
},
|
},
|
||||||
@@ -1171,7 +1171,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "End date must be after start date",
|
"end_before_start": "End date must be after start date",
|
||||||
"start_in_past": "Start date is in the past",
|
"start_in_past": "Start date is in the past",
|
||||||
"empty_body": "Message body is empty — recipients will receive a blank reply"
|
"empty_body": "Message body is empty - recipients will receive a blank reply"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2313,7 +2313,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Welcome to Bulwark Mail",
|
"title": "Welcome to Bulwark Mail",
|
||||||
"description": "Explore a fully-featured webmail client — right in your browser. All data stays on your device, so feel free to test everything.",
|
"description": "Explore a fully-featured webmail client - right in your browser. All data stays on your device, so feel free to test everything.",
|
||||||
"feature_email": "Read & compose email",
|
"feature_email": "Read & compose email",
|
||||||
"feature_organize": "Tags, stars & folders",
|
"feature_organize": "Tags, stars & folders",
|
||||||
"feature_shortcuts": "Keyboard shortcuts",
|
"feature_shortcuts": "Keyboard shortcuts",
|
||||||
@@ -2540,15 +2540,15 @@
|
|||||||
"create_event_title": "Create an event",
|
"create_event_title": "Create an event",
|
||||||
"create_event_desc": "Click this button to create a new calendar event. You can set a title, date, time, and add participants.",
|
"create_event_desc": "Click this button to create a new calendar event. You can set a title, date, time, and add participants.",
|
||||||
"event_modal_title": "Event details",
|
"event_modal_title": "Event details",
|
||||||
"event_modal_desc": "Here's the event form. Fill in the title, pick a date and time, add a location or participants. Hit save when you're done — or close it and move on.",
|
"event_modal_desc": "Here's the event form. Fill in the title, pick a date and time, add a location or participants. Hit save when you're done - or close it and move on.",
|
||||||
"contacts_list_title": "Your contacts",
|
"contacts_list_title": "Your contacts",
|
||||||
"contacts_list_desc": "Here are your contacts. Click any contact to see their full details on the right. You can also create new contacts, import vCards, or organize contacts into groups.",
|
"contacts_list_desc": "Here are your contacts. Click any contact to see their full details on the right. You can also create new contacts, import vCards, or organize contacts into groups.",
|
||||||
"settings_tabs_title": "Settings menu",
|
"settings_tabs_title": "Settings menu",
|
||||||
"settings_tabs_desc": "Here are all the settings categories. Customize your appearance, manage identities, set up email filters, configure your calendar, and much more.",
|
"settings_tabs_desc": "Here are all the settings categories. Customize your appearance, manage identities, set up email filters, configure your calendar, and much more.",
|
||||||
"files_title": "File storage",
|
"files_title": "File storage",
|
||||||
"files_desc": "Your file browser lets you upload, organize, and share files — like a personal cloud drive built into your mail.",
|
"files_desc": "Your file browser lets you upload, organize, and share files - like a personal cloud drive built into your mail.",
|
||||||
"demo_banner_title": "Demo controls",
|
"demo_banner_title": "Demo controls",
|
||||||
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
|
"demo_banner_desc": "You're in demo mode - everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
|
||||||
"quota_title": "Storage usage",
|
"quota_title": "Storage usage",
|
||||||
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
|
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Aviso legal",
|
"imprint": "Aviso legal",
|
||||||
"privacy_policy": "Política de privacidad",
|
"privacy_policy": "Política de privacidad",
|
||||||
"try_demo": "Probar demo",
|
"try_demo": "Probar demo",
|
||||||
"demo_description": "Explora con datos de ejemplo — sin cuenta necesaria",
|
"demo_description": "Explora con datos de ejemplo - sin cuenta necesaria",
|
||||||
"demo_launching": "Iniciando demo...",
|
"demo_launching": "Iniciando demo...",
|
||||||
"demo_login_button": "Iniciar demo",
|
"demo_login_button": "Iniciar demo",
|
||||||
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
|
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
|
||||||
"demo_no_signup": "Sin registro — explora libremente con datos de ejemplo",
|
"demo_no_signup": "Sin registro - explora libremente con datos de ejemplo",
|
||||||
"oauth_completing": "Completando inicio de sesión...",
|
"oauth_completing": "Completando inicio de sesión...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Error de autenticación",
|
"title": "Error de autenticación",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio",
|
"end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio",
|
||||||
"start_in_past": "La fecha de inicio está en el pasado",
|
"start_in_past": "La fecha de inicio está en el pasado",
|
||||||
"empty_body": "El cuerpo del mensaje está vacío — los destinatarios recibirán una respuesta en blanco"
|
"empty_body": "El cuerpo del mensaje está vacío - los destinatarios recibirán una respuesta en blanco"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Bienvenido a Bulwark Mail",
|
"title": "Bienvenido a Bulwark Mail",
|
||||||
"description": "Explora un cliente de correo web completo — directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
|
"description": "Explora un cliente de correo web completo - directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
|
||||||
"feature_email": "Leer y redactar correos",
|
"feature_email": "Leer y redactar correos",
|
||||||
"feature_organize": "Etiquetas, estrellas y carpetas",
|
"feature_organize": "Etiquetas, estrellas y carpetas",
|
||||||
"feature_shortcuts": "Atajos de teclado",
|
"feature_shortcuts": "Atajos de teclado",
|
||||||
@@ -2533,7 +2533,7 @@
|
|||||||
"create_event_title": "Crear un evento",
|
"create_event_title": "Crear un evento",
|
||||||
"create_event_desc": "Haz clic en este botón para crear un nuevo evento de calendario. Puedes establecer un título, fecha, hora y añadir participantes.",
|
"create_event_desc": "Haz clic en este botón para crear un nuevo evento de calendario. Puedes establecer un título, fecha, hora y añadir participantes.",
|
||||||
"event_modal_title": "Detalles del evento",
|
"event_modal_title": "Detalles del evento",
|
||||||
"event_modal_desc": "Aquí está el formulario del evento. Rellena el título, elige una fecha y hora, añade una ubicación o participantes. Pulsa guardar cuando hayas terminado — o ciérralo y continúa.",
|
"event_modal_desc": "Aquí está el formulario del evento. Rellena el título, elige una fecha y hora, añade una ubicación o participantes. Pulsa guardar cuando hayas terminado - o ciérralo y continúa.",
|
||||||
"contacts_list_title": "Tus contactos",
|
"contacts_list_title": "Tus contactos",
|
||||||
"contacts_list_desc": "Aquí están tus contactos. Haz clic en cualquier contacto para ver sus detalles. Puedes crear contactos nuevos o importar vCards.",
|
"contacts_list_desc": "Aquí están tus contactos. Haz clic en cualquier contacto para ver sus detalles. Puedes crear contactos nuevos o importar vCards.",
|
||||||
"files_title": "Almacenamiento de archivos",
|
"files_title": "Almacenamiento de archivos",
|
||||||
@@ -2541,7 +2541,7 @@
|
|||||||
"settings_tabs_desc": "Aquí están todas las categorías de ajustes. Personaliza la apariencia, gestiona identidades, configura filtros de correo, ajusta tu calendario y mucho más.",
|
"settings_tabs_desc": "Aquí están todas las categorías de ajustes. Personaliza la apariencia, gestiona identidades, configura filtros de correo, ajusta tu calendario y mucho más.",
|
||||||
"files_desc": "Tu explorador de archivos para subir, organizar y compartir archivos.",
|
"files_desc": "Tu explorador de archivos para subir, organizar y compartir archivos.",
|
||||||
"demo_banner_title": "Controles de demo",
|
"demo_banner_title": "Controles de demo",
|
||||||
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
|
"demo_banner_desc": "Estás en modo demo - todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
|
||||||
"quota_title": "Uso de almacenamiento",
|
"quota_title": "Uso de almacenamiento",
|
||||||
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
|
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Mentions légales",
|
"imprint": "Mentions légales",
|
||||||
"privacy_policy": "Politique de confidentialité",
|
"privacy_policy": "Politique de confidentialité",
|
||||||
"try_demo": "Essayer la démo",
|
"try_demo": "Essayer la démo",
|
||||||
"demo_description": "Explorez avec des données d'exemple — aucun compte nécessaire",
|
"demo_description": "Explorez avec des données d'exemple - aucun compte nécessaire",
|
||||||
"demo_launching": "Lancement de la démo...",
|
"demo_launching": "Lancement de la démo...",
|
||||||
"demo_login_button": "Lancer la démo",
|
"demo_login_button": "Lancer la démo",
|
||||||
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
|
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
|
||||||
"demo_no_signup": "Aucune inscription — explorez librement avec des données d'exemple",
|
"demo_no_signup": "Aucune inscription - explorez librement avec des données d'exemple",
|
||||||
"oauth_completing": "Connexion en cours...",
|
"oauth_completing": "Connexion en cours...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Échec de l'authentification",
|
"title": "Échec de l'authentification",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "La date de fin doit être postérieure à la date de début",
|
"end_before_start": "La date de fin doit être postérieure à la date de début",
|
||||||
"start_in_past": "La date de début est dans le passé",
|
"start_in_past": "La date de début est dans le passé",
|
||||||
"empty_body": "Le corps du message est vide — les destinataires recevront une réponse vide"
|
"empty_body": "Le corps du message est vide - les destinataires recevront une réponse vide"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Bienvenue sur Bulwark Mail",
|
"title": "Bienvenue sur Bulwark Mail",
|
||||||
"description": "Explorez un client webmail complet — directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
|
"description": "Explorez un client webmail complet - directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
|
||||||
"feature_email": "Lire et rédiger des e-mails",
|
"feature_email": "Lire et rédiger des e-mails",
|
||||||
"feature_organize": "Tags, étoiles et dossiers",
|
"feature_organize": "Tags, étoiles et dossiers",
|
||||||
"feature_shortcuts": "Raccourcis clavier",
|
"feature_shortcuts": "Raccourcis clavier",
|
||||||
@@ -2533,15 +2533,15 @@
|
|||||||
"create_event_title": "Créer un événement",
|
"create_event_title": "Créer un événement",
|
||||||
"create_event_desc": "Cliquez sur ce bouton pour créer un nouvel événement. Vous pouvez définir un titre, une date, une heure et ajouter des participants.",
|
"create_event_desc": "Cliquez sur ce bouton pour créer un nouvel événement. Vous pouvez définir un titre, une date, une heure et ajouter des participants.",
|
||||||
"event_modal_title": "Détails de l'événement",
|
"event_modal_title": "Détails de l'événement",
|
||||||
"event_modal_desc": "Voici le formulaire de l'événement. Remplissez le titre, choisissez une date et une heure, ajoutez un lieu ou des participants. Cliquez sur enregistrer quand vous avez terminé — ou fermez-le et passez à autre chose.",
|
"event_modal_desc": "Voici le formulaire de l'événement. Remplissez le titre, choisissez une date et une heure, ajoutez un lieu ou des participants. Cliquez sur enregistrer quand vous avez terminé - ou fermez-le et passez à autre chose.",
|
||||||
"contacts_list_title": "Vos contacts",
|
"contacts_list_title": "Vos contacts",
|
||||||
"contacts_list_desc": "Voici vos contacts. Cliquez sur un contact pour voir ses détails à droite. Vous pouvez aussi créer de nouveaux contacts, importer des vCards ou organiser les contacts en groupes.",
|
"contacts_list_desc": "Voici vos contacts. Cliquez sur un contact pour voir ses détails à droite. Vous pouvez aussi créer de nouveaux contacts, importer des vCards ou organiser les contacts en groupes.",
|
||||||
"files_title": "Stockage de fichiers",
|
"files_title": "Stockage de fichiers",
|
||||||
"settings_tabs_title": "Menu des paramètres",
|
"settings_tabs_title": "Menu des paramètres",
|
||||||
"settings_tabs_desc": "Voici toutes les catégories de paramètres. Personnalisez l'apparence, gérez les identités, configurez les filtres de messagerie, paramétrez votre calendrier et bien plus encore.",
|
"settings_tabs_desc": "Voici toutes les catégories de paramètres. Personnalisez l'apparence, gérez les identités, configurez les filtres de messagerie, paramétrez votre calendrier et bien plus encore.",
|
||||||
"files_desc": "Votre gestionnaire de fichiers vous permet de téléverser, organiser et partager des fichiers — comme un cloud personnel intégré à votre messagerie.",
|
"files_desc": "Votre gestionnaire de fichiers vous permet de téléverser, organiser et partager des fichiers - comme un cloud personnel intégré à votre messagerie.",
|
||||||
"demo_banner_title": "Contrôles de démo",
|
"demo_banner_title": "Contrôles de démo",
|
||||||
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
|
"demo_banner_desc": "Vous êtes en mode démo - tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
|
||||||
"quota_title": "Utilisation du stockage",
|
"quota_title": "Utilisation du stockage",
|
||||||
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
|
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Note legali",
|
"imprint": "Note legali",
|
||||||
"privacy_policy": "Informativa sulla privacy",
|
"privacy_policy": "Informativa sulla privacy",
|
||||||
"try_demo": "Prova la demo",
|
"try_demo": "Prova la demo",
|
||||||
"demo_description": "Esplora con dati di esempio — nessun account necessario",
|
"demo_description": "Esplora con dati di esempio - nessun account necessario",
|
||||||
"demo_launching": "Avvio demo...",
|
"demo_launching": "Avvio demo...",
|
||||||
"demo_login_button": "Avvia demo",
|
"demo_login_button": "Avvia demo",
|
||||||
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
|
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
|
||||||
"demo_no_signup": "Nessuna registrazione — esplora liberamente con dati di esempio",
|
"demo_no_signup": "Nessuna registrazione - esplora liberamente con dati di esempio",
|
||||||
"oauth_completing": "Completamento dell'accesso...",
|
"oauth_completing": "Completamento dell'accesso...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Autenticazione non riuscita",
|
"title": "Autenticazione non riuscita",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "La data di fine deve essere successiva alla data di inizio",
|
"end_before_start": "La data di fine deve essere successiva alla data di inizio",
|
||||||
"start_in_past": "La data di inizio è nel passato",
|
"start_in_past": "La data di inizio è nel passato",
|
||||||
"empty_body": "Il corpo del messaggio è vuoto — i destinatari riceveranno una risposta vuota"
|
"empty_body": "Il corpo del messaggio è vuoto - i destinatari riceveranno una risposta vuota"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Benvenuto su Bulwark Mail",
|
"title": "Benvenuto su Bulwark Mail",
|
||||||
"description": "Esplora un client webmail completo — direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
|
"description": "Esplora un client webmail completo - direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
|
||||||
"feature_email": "Leggere e scrivere email",
|
"feature_email": "Leggere e scrivere email",
|
||||||
"feature_organize": "Tag, stelle e cartelle",
|
"feature_organize": "Tag, stelle e cartelle",
|
||||||
"feature_shortcuts": "Scorciatoie da tastiera",
|
"feature_shortcuts": "Scorciatoie da tastiera",
|
||||||
@@ -2533,15 +2533,15 @@
|
|||||||
"create_event_title": "Crea un evento",
|
"create_event_title": "Crea un evento",
|
||||||
"create_event_desc": "Fai clic su questo pulsante per creare un nuovo evento del calendario. Puoi impostare un titolo, data, ora e aggiungere partecipanti.",
|
"create_event_desc": "Fai clic su questo pulsante per creare un nuovo evento del calendario. Puoi impostare un titolo, data, ora e aggiungere partecipanti.",
|
||||||
"event_modal_title": "Dettagli evento",
|
"event_modal_title": "Dettagli evento",
|
||||||
"event_modal_desc": "Ecco il modulo dell'evento. Inserisci il titolo, scegli data e ora, aggiungi un luogo o partecipanti. Premi salva quando hai finito — o chiudilo e vai avanti.",
|
"event_modal_desc": "Ecco il modulo dell'evento. Inserisci il titolo, scegli data e ora, aggiungi un luogo o partecipanti. Premi salva quando hai finito - o chiudilo e vai avanti.",
|
||||||
"contacts_list_title": "I tuoi contatti",
|
"contacts_list_title": "I tuoi contatti",
|
||||||
"contacts_list_desc": "Ecco i tuoi contatti. Clicca su un contatto per vedere i suoi dettagli a destra. Puoi anche creare nuovi contatti, importare vCard o organizzare i contatti in gruppi.",
|
"contacts_list_desc": "Ecco i tuoi contatti. Clicca su un contatto per vedere i suoi dettagli a destra. Puoi anche creare nuovi contatti, importare vCard o organizzare i contatti in gruppi.",
|
||||||
"files_title": "Archiviazione file",
|
"files_title": "Archiviazione file",
|
||||||
"settings_tabs_title": "Menu impostazioni",
|
"settings_tabs_title": "Menu impostazioni",
|
||||||
"settings_tabs_desc": "Ecco tutte le categorie di impostazioni. Personalizza l'aspetto, gestisci le identità, configura i filtri email, imposta il calendario e molto altro.",
|
"settings_tabs_desc": "Ecco tutte le categorie di impostazioni. Personalizza l'aspetto, gestisci le identità, configura i filtri email, imposta il calendario e molto altro.",
|
||||||
"files_desc": "Il tuo file browser ti permette di caricare, organizzare e condividere file — come un cloud personale integrato nella tua posta.",
|
"files_desc": "Il tuo file browser ti permette di caricare, organizzare e condividere file - come un cloud personale integrato nella tua posta.",
|
||||||
"demo_banner_title": "Controlli demo",
|
"demo_banner_title": "Controlli demo",
|
||||||
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
|
"demo_banner_desc": "Sei in modalità demo - tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
|
||||||
"quota_title": "Utilizzo dello spazio",
|
"quota_title": "Utilizzo dello spazio",
|
||||||
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
|
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "サイト運営者情報",
|
"imprint": "サイト運営者情報",
|
||||||
"privacy_policy": "プライバシーポリシー",
|
"privacy_policy": "プライバシーポリシー",
|
||||||
"try_demo": "デモを試す",
|
"try_demo": "デモを試す",
|
||||||
"demo_description": "サンプルデータで探索 — アカウント不要",
|
"demo_description": "サンプルデータで探索 - アカウント不要",
|
||||||
"demo_launching": "デモを起動中...",
|
"demo_launching": "デモを起動中...",
|
||||||
"demo_login_button": "デモを開始",
|
"demo_login_button": "デモを開始",
|
||||||
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
|
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
|
||||||
"demo_no_signup": "登録不要 — サンプルデータで自由に探索",
|
"demo_no_signup": "登録不要 - サンプルデータで自由に探索",
|
||||||
"oauth_completing": "サインイン処理中...",
|
"oauth_completing": "サインイン処理中...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "認証に失敗しました",
|
"title": "認証に失敗しました",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "終了日は開始日より後に設定してください",
|
"end_before_start": "終了日は開始日より後に設定してください",
|
||||||
"start_in_past": "開始日が過去の日付です",
|
"start_in_past": "開始日が過去の日付です",
|
||||||
"empty_body": "メッセージ本文が空です — 受信者には空の返信が届きます"
|
"empty_body": "メッセージ本文が空です - 受信者には空の返信が届きます"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2533,7 +2533,7 @@
|
|||||||
"create_event_title": "イベントを作成",
|
"create_event_title": "イベントを作成",
|
||||||
"create_event_desc": "このボタンをクリックして新しいカレンダーイベントを作成します。タイトル、日付、時間を設定し、参加者を追加できます。",
|
"create_event_desc": "このボタンをクリックして新しいカレンダーイベントを作成します。タイトル、日付、時間を設定し、参加者を追加できます。",
|
||||||
"event_modal_title": "イベント詳細",
|
"event_modal_title": "イベント詳細",
|
||||||
"event_modal_desc": "イベントフォームです。タイトルを入力し、日時を選択し、場所や参加者を追加してください。完了したら保存をクリック — または閉じて次に進みましょう。",
|
"event_modal_desc": "イベントフォームです。タイトルを入力し、日時を選択し、場所や参加者を追加してください。完了したら保存をクリック - または閉じて次に進みましょう。",
|
||||||
"contacts_list_title": "連絡先一覧",
|
"contacts_list_title": "連絡先一覧",
|
||||||
"contacts_list_desc": "連絡先の一覧です。連絡先をクリックすると右側に詳細が表示されます。新しい連絡先の作成、vCardのインポート、グループへの整理もできます。",
|
"contacts_list_desc": "連絡先の一覧です。連絡先をクリックすると右側に詳細が表示されます。新しい連絡先の作成、vCardのインポート、グループへの整理もできます。",
|
||||||
"files_title": "ファイルストレージ",
|
"files_title": "ファイルストレージ",
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Rekvizīti",
|
"imprint": "Rekvizīti",
|
||||||
"privacy_policy": "Privātuma politika",
|
"privacy_policy": "Privātuma politika",
|
||||||
"try_demo": "Izmēģināt demo",
|
"try_demo": "Izmēģināt demo",
|
||||||
"demo_description": "Izpētiet ar paraugdatiem — konts nav nepieciešams",
|
"demo_description": "Izpētiet ar paraugdatiem - konts nav nepieciešams",
|
||||||
"demo_launching": "Demo palaišana...",
|
"demo_launching": "Demo palaišana...",
|
||||||
"demo_login_button": "Palaist demo",
|
"demo_login_button": "Palaist demo",
|
||||||
"demo_tagline": "Izmēģiniet pilnvērtīgu e-pasta klientu bez konta izveides.",
|
"demo_tagline": "Izmēģiniet pilnvērtīgu e-pasta klientu bez konta izveides.",
|
||||||
"demo_no_signup": "Reģistrācija nav nepieciešama — brīvi pētiet paraugdatus",
|
"demo_no_signup": "Reģistrācija nav nepieciešama - brīvi pētiet paraugdatus",
|
||||||
"oauth_completing": "Pabeidz pierakstīšanos...",
|
"oauth_completing": "Pabeidz pierakstīšanos...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Autentifikācijas kļūda",
|
"title": "Autentifikācijas kļūda",
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
"smime_sign_off": "Iespējot S/MIME parakstu",
|
"smime_sign_off": "Iespējot S/MIME parakstu",
|
||||||
"smime_encrypt_on": "S/MIME šifrēšana iespējota",
|
"smime_encrypt_on": "S/MIME šifrēšana iespējota",
|
||||||
"smime_encrypt_off": "Iespējot S/MIME šifrēšanu",
|
"smime_encrypt_off": "Iespējot S/MIME šifrēšanu",
|
||||||
"smime_encrypt_unavailable": "S/MIME šifrēšana nav pieejama — trūkst saņēmēja sertifikāta",
|
"smime_encrypt_unavailable": "S/MIME šifrēšana nav pieejama - trūkst saņēmēja sertifikāta",
|
||||||
"smime_unlock_title": "Atbloķēt S/MIME atslēgu",
|
"smime_unlock_title": "Atbloķēt S/MIME atslēgu",
|
||||||
"smime_unlock_message": "Ievadiet paroli, lai atbloķētu savu S/MIME parakstīšanas atslēgu.",
|
"smime_unlock_message": "Ievadiet paroli, lai atbloķētu savu S/MIME parakstīšanas atslēgu.",
|
||||||
"smime_unlock_button": "Atbloķēt",
|
"smime_unlock_button": "Atbloķēt",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "Beigu datumam jābūt vēlāk par sākuma datumu",
|
"end_before_start": "Beigu datumam jābūt vēlāk par sākuma datumu",
|
||||||
"start_in_past": "Sākuma datums ir pagātnē",
|
"start_in_past": "Sākuma datums ir pagātnē",
|
||||||
"empty_body": "Ziņojuma teksts ir tukšs — saņēmēji saņems tukšu atbildi"
|
"empty_body": "Ziņojuma teksts ir tukšs - saņēmēji saņems tukšu atbildi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2537,11 +2537,11 @@
|
|||||||
"contacts_list_title": "Jūsu kontakti",
|
"contacts_list_title": "Jūsu kontakti",
|
||||||
"contacts_list_desc": "Skatīt un rediģēt kontaktus, importēt vCard vai veidot grupas.",
|
"contacts_list_desc": "Skatīt un rediģēt kontaktus, importēt vCard vai veidot grupas.",
|
||||||
"settings_tabs_title": "Iestatījumu izvēlne",
|
"settings_tabs_title": "Iestatījumu izvēlne",
|
||||||
"settings_tabs_desc": "Šeit atradīsiet visas iestatījumu kategorijas — no izskata līdz drošībai.",
|
"settings_tabs_desc": "Šeit atradīsiet visas iestatījumu kategorijas - no izskata līdz drošībai.",
|
||||||
"files_title": "Failu krātuve",
|
"files_title": "Failu krātuve",
|
||||||
"files_desc": "Failu pārvaldnieks ļauj augšupielādēt un organizēt failus kā personīgo mākoņkrātuvi.",
|
"files_desc": "Failu pārvaldnieks ļauj augšupielādēt un organizēt failus kā personīgo mākoņkrātuvi.",
|
||||||
"demo_banner_title": "Demo vadība",
|
"demo_banner_title": "Demo vadība",
|
||||||
"demo_banner_desc": "Jūs esat demo režīmā — visi dati paliek jūsu pārlūkā. Nospiediet «Atiestatīt demo», lai sāktu no jauna.",
|
"demo_banner_desc": "Jūs esat demo režīmā - visi dati paliek jūsu pārlūkā. Nospiediet «Atiestatīt demo», lai sāktu no jauna.",
|
||||||
"quota_title": "Krātuves izmantošana",
|
"quota_title": "Krātuves izmantošana",
|
||||||
"quota_desc": "Sekojiet līdzi savas pastkastes aizpildījumam šeit."
|
"quota_desc": "Sekojiet līdzi savas pastkastes aizpildījumam šeit."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Colofon",
|
"imprint": "Colofon",
|
||||||
"privacy_policy": "Privacybeleid",
|
"privacy_policy": "Privacybeleid",
|
||||||
"try_demo": "Demo proberen",
|
"try_demo": "Demo proberen",
|
||||||
"demo_description": "Verken met voorbeeldgegevens — geen account nodig",
|
"demo_description": "Verken met voorbeeldgegevens - geen account nodig",
|
||||||
"demo_launching": "Demo starten...",
|
"demo_launching": "Demo starten...",
|
||||||
"demo_login_button": "Demo starten",
|
"demo_login_button": "Demo starten",
|
||||||
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
|
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
|
||||||
"demo_no_signup": "Geen registratie nodig — verken vrij met voorbeeldgegevens",
|
"demo_no_signup": "Geen registratie nodig - verken vrij met voorbeeldgegevens",
|
||||||
"oauth_completing": "Aanmelding voltooien...",
|
"oauth_completing": "Aanmelding voltooien...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authenticatie mislukt",
|
"title": "Authenticatie mislukt",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "De einddatum moet na de startdatum liggen",
|
"end_before_start": "De einddatum moet na de startdatum liggen",
|
||||||
"start_in_past": "De startdatum ligt in het verleden",
|
"start_in_past": "De startdatum ligt in het verleden",
|
||||||
"empty_body": "De berichttekst is leeg — ontvangers krijgen een leeg antwoord"
|
"empty_body": "De berichttekst is leeg - ontvangers krijgen een leeg antwoord"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Welkom bij Bulwark Mail",
|
"title": "Welkom bij Bulwark Mail",
|
||||||
"description": "Ontdek een volwaardige webmail-client — rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
|
"description": "Ontdek een volwaardige webmail-client - rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
|
||||||
"feature_email": "E-mails lezen en schrijven",
|
"feature_email": "E-mails lezen en schrijven",
|
||||||
"feature_organize": "Tags, sterren en mappen",
|
"feature_organize": "Tags, sterren en mappen",
|
||||||
"feature_shortcuts": "Sneltoetsen",
|
"feature_shortcuts": "Sneltoetsen",
|
||||||
@@ -2533,15 +2533,15 @@
|
|||||||
"create_event_title": "Evenement aanmaken",
|
"create_event_title": "Evenement aanmaken",
|
||||||
"create_event_desc": "Klik op deze knop om een nieuw agenda-evenement aan te maken. U kunt een titel, datum, tijd en deelnemers instellen.",
|
"create_event_desc": "Klik op deze knop om een nieuw agenda-evenement aan te maken. U kunt een titel, datum, tijd en deelnemers instellen.",
|
||||||
"event_modal_title": "Evenementdetails",
|
"event_modal_title": "Evenementdetails",
|
||||||
"event_modal_desc": "Hier is het evenementformulier. Vul de titel in, kies een datum en tijd, voeg een locatie of deelnemers toe. Klik op opslaan als u klaar bent — of sluit het en ga verder.",
|
"event_modal_desc": "Hier is het evenementformulier. Vul de titel in, kies een datum en tijd, voeg een locatie of deelnemers toe. Klik op opslaan als u klaar bent - of sluit het en ga verder.",
|
||||||
"contacts_list_title": "Uw contacten",
|
"contacts_list_title": "Uw contacten",
|
||||||
"contacts_list_desc": "Hier zijn uw contacten. Klik op een contact om de details rechts te bekijken. U kunt ook nieuwe contacten aanmaken, vCards importeren of contacten in groepen organiseren.",
|
"contacts_list_desc": "Hier zijn uw contacten. Klik op een contact om de details rechts te bekijken. U kunt ook nieuwe contacten aanmaken, vCards importeren of contacten in groepen organiseren.",
|
||||||
"files_title": "Bestandsopslag",
|
"files_title": "Bestandsopslag",
|
||||||
"settings_tabs_title": "Instellingenmenu",
|
"settings_tabs_title": "Instellingenmenu",
|
||||||
"settings_tabs_desc": "Hier vindt u alle instellingscategorieën. Pas het uiterlijk aan, beheer identiteiten, stel e-mailfilters in, configureer uw agenda en nog veel meer.",
|
"settings_tabs_desc": "Hier vindt u alle instellingscategorieën. Pas het uiterlijk aan, beheer identiteiten, stel e-mailfilters in, configureer uw agenda en nog veel meer.",
|
||||||
"files_desc": "Uw bestandsbrowser laat u bestanden uploaden, organiseren en delen — als een persoonlijke cloud geïntegreerd in uw mail.",
|
"files_desc": "Uw bestandsbrowser laat u bestanden uploaden, organiseren en delen - als een persoonlijke cloud geïntegreerd in uw mail.",
|
||||||
"demo_banner_title": "Demo-bediening",
|
"demo_banner_title": "Demo-bediening",
|
||||||
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
|
"demo_banner_desc": "U bent in demomodus - alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
|
||||||
"quota_title": "Opslaggebruik",
|
"quota_title": "Opslaggebruik",
|
||||||
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
|
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Impressum",
|
"imprint": "Impressum",
|
||||||
"privacy_policy": "Polityka prywatności",
|
"privacy_policy": "Polityka prywatności",
|
||||||
"try_demo": "Wypróbuj demo",
|
"try_demo": "Wypróbuj demo",
|
||||||
"demo_description": "Poznaj aplikację z przykładowymi danymi — bez potrzeby zakładania konta",
|
"demo_description": "Poznaj aplikację z przykładowymi danymi - bez potrzeby zakładania konta",
|
||||||
"demo_launching": "Uruchamianie demo...",
|
"demo_launching": "Uruchamianie demo...",
|
||||||
"demo_login_button": "Uruchom demo",
|
"demo_login_button": "Uruchom demo",
|
||||||
"demo_tagline": "Wypróbuj w pełni funkcjonalnego klienta poczty. Konto nie jest wymagane.",
|
"demo_tagline": "Wypróbuj w pełni funkcjonalnego klienta poczty. Konto nie jest wymagane.",
|
||||||
"demo_no_signup": "Bez rejestracji — eksploruj swobodnie z przykładowymi danymi",
|
"demo_no_signup": "Bez rejestracji - eksploruj swobodnie z przykładowymi danymi",
|
||||||
"oauth_completing": "Kończenie logowania...",
|
"oauth_completing": "Kończenie logowania...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Uwierzytelnianie nie powiodło się",
|
"title": "Uwierzytelnianie nie powiodło się",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "Data zakończenia musi być późniejsza niż data rozpoczęcia",
|
"end_before_start": "Data zakończenia musi być późniejsza niż data rozpoczęcia",
|
||||||
"start_in_past": "Data rozpoczęcia jest w przeszłości",
|
"start_in_past": "Data rozpoczęcia jest w przeszłości",
|
||||||
"empty_body": "Treść wiadomości jest pusta — odbiorcy otrzymają pustą odpowiedź"
|
"empty_body": "Treść wiadomości jest pusta - odbiorcy otrzymają pustą odpowiedź"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Witaj w Bulwark Mail",
|
"title": "Witaj w Bulwark Mail",
|
||||||
"description": "Poznaj w pełni funkcjonalnego klienta poczty webowej — bezpośrednio w przeglądarce. Wszystkie dane pozostają na Twoim urządzeniu, więc możesz śmiało wszystko przetestować.",
|
"description": "Poznaj w pełni funkcjonalnego klienta poczty webowej - bezpośrednio w przeglądarce. Wszystkie dane pozostają na Twoim urządzeniu, więc możesz śmiało wszystko przetestować.",
|
||||||
"feature_email": "Czytaj i pisz wiadomości e-mail",
|
"feature_email": "Czytaj i pisz wiadomości e-mail",
|
||||||
"feature_organize": "Etykiety, gwiazdki i foldery",
|
"feature_organize": "Etykiety, gwiazdki i foldery",
|
||||||
"feature_shortcuts": "Skróty klawiszowe",
|
"feature_shortcuts": "Skróty klawiszowe",
|
||||||
@@ -2533,15 +2533,15 @@
|
|||||||
"create_event_title": "Utwórz wydarzenie",
|
"create_event_title": "Utwórz wydarzenie",
|
||||||
"create_event_desc": "Kliknij ten przycisk, aby utworzyć nowe wydarzenie w kalendarzu. Możesz ustawić tytuł, datę, godzinę i dodać uczestników.",
|
"create_event_desc": "Kliknij ten przycisk, aby utworzyć nowe wydarzenie w kalendarzu. Możesz ustawić tytuł, datę, godzinę i dodać uczestników.",
|
||||||
"event_modal_title": "Szczegóły wydarzenia",
|
"event_modal_title": "Szczegóły wydarzenia",
|
||||||
"event_modal_desc": "To jest formularz wydarzenia. Uzupełnij tytuł, wybierz datę i godzinę, dodaj lokalizację albo uczestników. Gdy skończysz, kliknij zapisz — albo zamknij okno i przejdź dalej.",
|
"event_modal_desc": "To jest formularz wydarzenia. Uzupełnij tytuł, wybierz datę i godzinę, dodaj lokalizację albo uczestników. Gdy skończysz, kliknij zapisz - albo zamknij okno i przejdź dalej.",
|
||||||
"contacts_list_title": "Twoje kontakty",
|
"contacts_list_title": "Twoje kontakty",
|
||||||
"contacts_list_desc": "Tutaj są Twoje kontakty. Kliknij dowolny kontakt, aby zobaczyć jego pełne dane po prawej stronie. Możesz też tworzyć nowe kontakty, importować vCardy i organizować kontakty w grupy.",
|
"contacts_list_desc": "Tutaj są Twoje kontakty. Kliknij dowolny kontakt, aby zobaczyć jego pełne dane po prawej stronie. Możesz też tworzyć nowe kontakty, importować vCardy i organizować kontakty w grupy.",
|
||||||
"settings_tabs_title": "Menu ustawień",
|
"settings_tabs_title": "Menu ustawień",
|
||||||
"settings_tabs_desc": "Tutaj znajdują się wszystkie kategorie ustawień. Dostosuj wygląd, zarządzaj tożsamościami, skonfiguruj filtry poczty, ustawienia kalendarza i wiele więcej.",
|
"settings_tabs_desc": "Tutaj znajdują się wszystkie kategorie ustawień. Dostosuj wygląd, zarządzaj tożsamościami, skonfiguruj filtry poczty, ustawienia kalendarza i wiele więcej.",
|
||||||
"files_title": "Przechowywanie plików",
|
"files_title": "Przechowywanie plików",
|
||||||
"files_desc": "Przeglądarka plików pozwala przesyłać, porządkować i udostępniać pliki — jak osobisty dysk w chmurze wbudowany w Twoją pocztę.",
|
"files_desc": "Przeglądarka plików pozwala przesyłać, porządkować i udostępniać pliki - jak osobisty dysk w chmurze wbudowany w Twoją pocztę.",
|
||||||
"demo_banner_title": "Sterowanie wersją demo",
|
"demo_banner_title": "Sterowanie wersją demo",
|
||||||
"demo_banner_desc": "Jesteś w trybie demo — wszystko pozostaje w Twojej przeglądarce. W każdej chwili kliknij „Reset Demo”, aby zacząć od nowa z czystymi danymi przykładowymi.",
|
"demo_banner_desc": "Jesteś w trybie demo - wszystko pozostaje w Twojej przeglądarce. W każdej chwili kliknij „Reset Demo”, aby zacząć od nowa z czystymi danymi przykładowymi.",
|
||||||
"quota_title": "Wykorzystanie miejsca",
|
"quota_title": "Wykorzystanie miejsca",
|
||||||
"quota_desc": "Tutaj możesz śledzić rozmiar swojej skrzynki pocztowej. Okrąg wypełnia się wraz ze wzrostem użycia przestrzeni."
|
"quota_desc": "Tutaj możesz śledzić rozmiar swojej skrzynki pocztowej. Okrąg wypełnia się wraz ze wzrostem użycia przestrzeni."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Informações legais",
|
"imprint": "Informações legais",
|
||||||
"privacy_policy": "Política de privacidade",
|
"privacy_policy": "Política de privacidade",
|
||||||
"try_demo": "Experimentar demo",
|
"try_demo": "Experimentar demo",
|
||||||
"demo_description": "Explore com dados de exemplo — sem conta necessária",
|
"demo_description": "Explore com dados de exemplo - sem conta necessária",
|
||||||
"demo_launching": "Iniciando demo...",
|
"demo_launching": "Iniciando demo...",
|
||||||
"demo_login_button": "Iniciar demo",
|
"demo_login_button": "Iniciar demo",
|
||||||
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
|
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
|
||||||
"demo_no_signup": "Sem registo — explore livremente com dados de exemplo",
|
"demo_no_signup": "Sem registo - explore livremente com dados de exemplo",
|
||||||
"oauth_completing": "Concluindo login...",
|
"oauth_completing": "Concluindo login...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Falha na autenticação",
|
"title": "Falha na autenticação",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "A data de fim deve ser posterior à data de início",
|
"end_before_start": "A data de fim deve ser posterior à data de início",
|
||||||
"start_in_past": "A data de início está no passado",
|
"start_in_past": "A data de início está no passado",
|
||||||
"empty_body": "O corpo da mensagem está vazio — os destinatários receberão uma resposta em branco"
|
"empty_body": "O corpo da mensagem está vazio - os destinatários receberão uma resposta em branco"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Bem-vindo ao Bulwark Mail",
|
"title": "Bem-vindo ao Bulwark Mail",
|
||||||
"description": "Explore um cliente de webmail completo — diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
|
"description": "Explore um cliente de webmail completo - diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
|
||||||
"feature_email": "Ler e escrever e-mails",
|
"feature_email": "Ler e escrever e-mails",
|
||||||
"feature_organize": "Tags, estrelas e pastas",
|
"feature_organize": "Tags, estrelas e pastas",
|
||||||
"feature_shortcuts": "Atalhos de teclado",
|
"feature_shortcuts": "Atalhos de teclado",
|
||||||
@@ -2533,15 +2533,15 @@
|
|||||||
"create_event_title": "Criar um evento",
|
"create_event_title": "Criar um evento",
|
||||||
"create_event_desc": "Clique neste botão para criar um novo evento no calendário. Você pode definir um título, data, hora e adicionar participantes.",
|
"create_event_desc": "Clique neste botão para criar um novo evento no calendário. Você pode definir um título, data, hora e adicionar participantes.",
|
||||||
"event_modal_title": "Detalhes do evento",
|
"event_modal_title": "Detalhes do evento",
|
||||||
"event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar — ou feche e siga em frente.",
|
"event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar - ou feche e siga em frente.",
|
||||||
"contacts_list_title": "Seus contatos",
|
"contacts_list_title": "Seus contatos",
|
||||||
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
||||||
"files_title": "Armazenamento de ficheiros",
|
"files_title": "Armazenamento de ficheiros",
|
||||||
"settings_tabs_title": "Menu de configurações",
|
"settings_tabs_title": "Menu de configurações",
|
||||||
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
||||||
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros — como uma nuvem pessoal integrada no seu e-mail.",
|
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros - como uma nuvem pessoal integrada no seu e-mail.",
|
||||||
"demo_banner_title": "Controlos de demonstração",
|
"demo_banner_title": "Controlos de demonstração",
|
||||||
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
"demo_banner_desc": "Está no modo de demonstração - tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
||||||
"quota_title": "Utilização do armazenamento",
|
"quota_title": "Utilização do armazenamento",
|
||||||
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Выходные данные",
|
"imprint": "Выходные данные",
|
||||||
"privacy_policy": "Политика конфиденциальности",
|
"privacy_policy": "Политика конфиденциальности",
|
||||||
"try_demo": "Попробовать демо",
|
"try_demo": "Попробовать демо",
|
||||||
"demo_description": "Исследуйте с примерными данными — аккаунт не нужен",
|
"demo_description": "Исследуйте с примерными данными - аккаунт не нужен",
|
||||||
"demo_launching": "Запуск демо...",
|
"demo_launching": "Запуск демо...",
|
||||||
"demo_login_button": "Запустить демо",
|
"demo_login_button": "Запустить демо",
|
||||||
"demo_tagline": "Попробуйте полнофункциональный почтовый клиент. Аккаунт не нужен.",
|
"demo_tagline": "Попробуйте полнофункциональный почтовый клиент. Аккаунт не нужен.",
|
||||||
"demo_no_signup": "Регистрация не требуется — исследуйте свободно с примерными данными",
|
"demo_no_signup": "Регистрация не требуется - исследуйте свободно с примерными данными",
|
||||||
"oauth_completing": "Завершение входа...",
|
"oauth_completing": "Завершение входа...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Ошибка аутентификации",
|
"title": "Ошибка аутентификации",
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
"smime_sign_off": "Включить подпись S/MIME",
|
"smime_sign_off": "Включить подпись S/MIME",
|
||||||
"smime_encrypt_on": "Шифрование S/MIME включено",
|
"smime_encrypt_on": "Шифрование S/MIME включено",
|
||||||
"smime_encrypt_off": "Включить шифрование S/MIME",
|
"smime_encrypt_off": "Включить шифрование S/MIME",
|
||||||
"smime_encrypt_unavailable": "Шифрование S/MIME недоступно — отсутствует сертификат получателя",
|
"smime_encrypt_unavailable": "Шифрование S/MIME недоступно - отсутствует сертификат получателя",
|
||||||
"smime_unlock_title": "Разблокировать ключ S/MIME",
|
"smime_unlock_title": "Разблокировать ключ S/MIME",
|
||||||
"smime_unlock_message": "Введите парольную фразу для разблокировки вашего ключа подписи S/MIME.",
|
"smime_unlock_message": "Введите парольную фразу для разблокировки вашего ключа подписи S/MIME.",
|
||||||
"smime_unlock_button": "Разблокировать",
|
"smime_unlock_button": "Разблокировать",
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "Дата окончания должна быть позже даты начала",
|
"end_before_start": "Дата окончания должна быть позже даты начала",
|
||||||
"start_in_past": "Дата начала в прошлом",
|
"start_in_past": "Дата начала в прошлом",
|
||||||
"empty_body": "Текст сообщения пуст — получатели получат пустой ответ"
|
"empty_body": "Текст сообщения пуст - получатели получат пустой ответ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Добро пожаловать в Bulwark Mail",
|
"title": "Добро пожаловать в Bulwark Mail",
|
||||||
"description": "Откройте для себя полнофункциональный веб-клиент электронной почты — прямо в браузере. Все данные хранятся на вашем устройстве, поэтому вы можете смело всё проверить.",
|
"description": "Откройте для себя полнофункциональный веб-клиент электронной почты - прямо в браузере. Все данные хранятся на вашем устройстве, поэтому вы можете смело всё проверить.",
|
||||||
"feature_email": "Чтение и написание писем",
|
"feature_email": "Чтение и написание писем",
|
||||||
"feature_organize": "Метки, звёздочки и папки",
|
"feature_organize": "Метки, звёздочки и папки",
|
||||||
"feature_shortcuts": "Сочетания клавиш",
|
"feature_shortcuts": "Сочетания клавиш",
|
||||||
@@ -2533,15 +2533,15 @@
|
|||||||
"create_event_title": "Создать событие",
|
"create_event_title": "Создать событие",
|
||||||
"create_event_desc": "Нажмите эту кнопку для создания нового события. Вы можете указать название, дату, время и добавить участников.",
|
"create_event_desc": "Нажмите эту кнопку для создания нового события. Вы можете указать название, дату, время и добавить участников.",
|
||||||
"event_modal_title": "Детали события",
|
"event_modal_title": "Детали события",
|
||||||
"event_modal_desc": "Это форма события. Заполните название, выберите дату и время, добавьте место или участников. Нажмите «Сохранить» после завершения — или закройте и продолжайте.",
|
"event_modal_desc": "Это форма события. Заполните название, выберите дату и время, добавьте место или участников. Нажмите «Сохранить» после завершения - или закройте и продолжайте.",
|
||||||
"contacts_list_title": "Ваши контакты",
|
"contacts_list_title": "Ваши контакты",
|
||||||
"contacts_list_desc": "Здесь ваши контакты. Нажмите на контакт для просмотра подробностей справа. Вы также можете создавать новые контакты, импортировать vCard или организовывать контакты в группы.",
|
"contacts_list_desc": "Здесь ваши контакты. Нажмите на контакт для просмотра подробностей справа. Вы также можете создавать новые контакты, импортировать vCard или организовывать контакты в группы.",
|
||||||
"settings_tabs_title": "Меню настроек",
|
"settings_tabs_title": "Меню настроек",
|
||||||
"settings_tabs_desc": "Здесь все категории настроек. Настройте внешний вид, управляйте идентификациями, настраивайте фильтры, конфигурируйте календарь и многое другое.",
|
"settings_tabs_desc": "Здесь все категории настроек. Настройте внешний вид, управляйте идентификациями, настраивайте фильтры, конфигурируйте календарь и многое другое.",
|
||||||
"files_title": "Хранилище файлов",
|
"files_title": "Хранилище файлов",
|
||||||
"files_desc": "Файловый менеджер позволяет загружать, организовывать и делиться файлами — как личное облачное хранилище в вашей почте.",
|
"files_desc": "Файловый менеджер позволяет загружать, организовывать и делиться файлами - как личное облачное хранилище в вашей почте.",
|
||||||
"demo_banner_title": "Элементы управления демо",
|
"demo_banner_title": "Элементы управления демо",
|
||||||
"demo_banner_desc": "Вы в демо-режиме — всё остаётся в вашем браузере. Нажмите «Сбросить демо» в любое время, чтобы начать заново с чистыми данными.",
|
"demo_banner_desc": "Вы в демо-режиме - всё остаётся в вашем браузере. Нажмите «Сбросить демо» в любое время, чтобы начать заново с чистыми данными.",
|
||||||
"quota_title": "Использование хранилища",
|
"quota_title": "Использование хранилища",
|
||||||
"quota_desc": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства."
|
"quota_desc": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
"imprint": "Вихідні дані",
|
"imprint": "Вихідні дані",
|
||||||
"privacy_policy": "Політика конфіденційності",
|
"privacy_policy": "Політика конфіденційності",
|
||||||
"try_demo": "Спробуйте демо",
|
"try_demo": "Спробуйте демо",
|
||||||
"demo_description": "Досліджуйте за зразками даних — обліковий запис не потрібен",
|
"demo_description": "Досліджуйте за зразками даних - обліковий запис не потрібен",
|
||||||
"demo_launching": "Запуск демо...",
|
"demo_launching": "Запуск демо...",
|
||||||
"demo_login_button": "Запустити демонстрацію",
|
"demo_login_button": "Запустити демонстрацію",
|
||||||
"demo_tagline": "Спробуйте повнофункціональний поштовий клієнт. Обліковий запис не потрібен.",
|
"demo_tagline": "Спробуйте повнофункціональний поштовий клієнт. Обліковий запис не потрібен.",
|
||||||
"demo_no_signup": "Реєстрація не потрібна — вільно досліджуйте з зразками даних",
|
"demo_no_signup": "Реєстрація не потрібна - вільно досліджуйте з зразками даних",
|
||||||
"oauth_completing": "Завершення входу...",
|
"oauth_completing": "Завершення входу...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Помилка автентифікації",
|
"title": "Помилка автентифікації",
|
||||||
@@ -923,7 +923,7 @@
|
|||||||
"close": "Закрити",
|
"close": "Закрити",
|
||||||
"invalid_email": "Введіть дійсну електронну адресу",
|
"invalid_email": "Введіть дійсну електронну адресу",
|
||||||
"already_added": "Цей відправник уже є надійним",
|
"already_added": "Цей відправник уже є надійним",
|
||||||
"save_error": "Не вдалося зберегти — подробиці перевірте в журналі налагодження Контактів",
|
"save_error": "Не вдалося зберегти - подробиці перевірте в журналі налагодження Контактів",
|
||||||
"use_address_book_label": "Синхронізація з адресною книгою",
|
"use_address_book_label": "Синхронізація з адресною книгою",
|
||||||
"use_address_book_description": "Зберігайте довірених відправників у спеціальній адресній книзі «Надійні відправники», щоб вони синхронізувалися на всіх ваших пристроях"
|
"use_address_book_description": "Зберігайте довірених відправників у спеціальній адресній книзі «Надійні відправники», щоб вони синхронізувалися на всіх ваших пристроях"
|
||||||
},
|
},
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "Дата завершення має бути після дати початку",
|
"end_before_start": "Дата завершення має бути після дати початку",
|
||||||
"start_in_past": "Дата початку в минулому",
|
"start_in_past": "Дата початку в минулому",
|
||||||
"empty_body": "Тіло повідомлення порожнє — одержувачі отримають порожню відповідь"
|
"empty_body": "Тіло повідомлення порожнє - одержувачі отримають порожню відповідь"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "Ласкаво просимо до Bulwark Mail",
|
"title": "Ласкаво просимо до Bulwark Mail",
|
||||||
"description": "Відкрийте для себе повнофункціональний клієнт веб-пошти — прямо у вашому браузері. Усі дані залишаються на вашому пристрої, тому сміливо тестуйте все.",
|
"description": "Відкрийте для себе повнофункціональний клієнт веб-пошти - прямо у вашому браузері. Усі дані залишаються на вашому пристрої, тому сміливо тестуйте все.",
|
||||||
"feature_email": "Читайте та створюйте електронний лист",
|
"feature_email": "Читайте та створюйте електронний лист",
|
||||||
"feature_organize": "Теги, зірочки та папки",
|
"feature_organize": "Теги, зірочки та папки",
|
||||||
"feature_shortcuts": "Комбінації клавіш",
|
"feature_shortcuts": "Комбінації клавіш",
|
||||||
@@ -2533,7 +2533,7 @@
|
|||||||
"create_event_title": "Створіть подію",
|
"create_event_title": "Створіть подію",
|
||||||
"create_event_desc": "Натисніть цю кнопку, щоб створити нову подію календаря. Ви можете встановити назву, дату, час і додати учасників.",
|
"create_event_desc": "Натисніть цю кнопку, щоб створити нову подію календаря. Ви можете встановити назву, дату, час і додати учасників.",
|
||||||
"event_modal_title": "Деталі події",
|
"event_modal_title": "Деталі події",
|
||||||
"event_modal_desc": "Ось форма події. Введіть назву, виберіть дату та час, додайте місце чи учасників. Натисніть «Зберегти», коли закінчите — або закрийте його та йдіть далі.",
|
"event_modal_desc": "Ось форма події. Введіть назву, виберіть дату та час, додайте місце чи учасників. Натисніть «Зберегти», коли закінчите - або закрийте його та йдіть далі.",
|
||||||
"contacts_list_title": "Ваші контакти",
|
"contacts_list_title": "Ваші контакти",
|
||||||
"contacts_list_desc": "Ось ваші контакти. Клацніть будь-який контакт, щоб побачити його повну інформацію праворуч. Ви також можете створювати нові контакти, імпортувати vCards або організовувати контакти в групи.",
|
"contacts_list_desc": "Ось ваші контакти. Клацніть будь-який контакт, щоб побачити його повну інформацію праворуч. Ви також можете створювати нові контакти, імпортувати vCards або організовувати контакти в групи.",
|
||||||
"settings_tabs_title": "Меню налаштувань",
|
"settings_tabs_title": "Меню налаштувань",
|
||||||
@@ -2541,7 +2541,7 @@
|
|||||||
"files_title": "Зберігання файлів",
|
"files_title": "Зберігання файлів",
|
||||||
"files_desc": "Ваш файловий браузер дозволяє завантажувати, упорядковувати та ділитися файлами, як особистий хмарний диск, вбудований у вашу пошту.",
|
"files_desc": "Ваш файловий браузер дозволяє завантажувати, упорядковувати та ділитися файлами, як особистий хмарний диск, вбудований у вашу пошту.",
|
||||||
"demo_banner_title": "Демонстраційні елементи керування",
|
"demo_banner_title": "Демонстраційні елементи керування",
|
||||||
"demo_banner_desc": "Ви в демонстраційному режимі — все залишається у вашому браузері. Будь-коли натисніть «Скинути демонстрацію», щоб почати заново з чистими зразками даних.",
|
"demo_banner_desc": "Ви в демонстраційному режимі - все залишається у вашому браузері. Будь-коли натисніть «Скинути демонстрацію», щоб почати заново з чистими зразками даних.",
|
||||||
"quota_title": "Використання сховища",
|
"quota_title": "Використання сховища",
|
||||||
"quota_desc": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця."
|
"quota_desc": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1166,7 +1166,7 @@
|
|||||||
"warnings": {
|
"warnings": {
|
||||||
"end_before_start": "结束日期必须晚于开始日期",
|
"end_before_start": "结束日期必须晚于开始日期",
|
||||||
"start_in_past": "开始日期已在过去",
|
"start_in_past": "开始日期已在过去",
|
||||||
"empty_body": "邮件正文为空——收件人将收到空白回复"
|
"empty_body": "邮件正文为空--收件人将收到空白回复"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
@@ -2306,7 +2306,7 @@
|
|||||||
},
|
},
|
||||||
"demo_welcome": {
|
"demo_welcome": {
|
||||||
"title": "欢迎使用 Bulwark Mail",
|
"title": "欢迎使用 Bulwark Mail",
|
||||||
"description": "探索功能齐全的网页邮件客户端——就在您的浏览器中。所有数据都保留在您的设备上,请随意体验所有功能。",
|
"description": "探索功能齐全的网页邮件客户端--就在您的浏览器中。所有数据都保留在您的设备上,请随意体验所有功能。",
|
||||||
"feature_email": "阅读和撰写邮件",
|
"feature_email": "阅读和撰写邮件",
|
||||||
"feature_organize": "标签、星标和文件夹",
|
"feature_organize": "标签、星标和文件夹",
|
||||||
"feature_shortcuts": "键盘快捷键",
|
"feature_shortcuts": "键盘快捷键",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.14",
|
"version": "1.4.14",
|
||||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ export function proxy(request: NextRequest) {
|
|||||||
`frame-ancestors ${frameAncestors}`,
|
`frame-ancestors ${frameAncestors}`,
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
// Skip intl middleware for /admin routes — they have their own layout
|
// Skip intl middleware for /admin routes - they have their own layout
|
||||||
const pathname = request.nextUrl.pathname;
|
const pathname = request.nextUrl.pathname;
|
||||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
||||||
|
|
||||||
// When localePrefix is 'always', paths that already have a locale prefix
|
// When localePrefix is 'always', paths that already have a locale prefix
|
||||||
// (e.g. /en/settings) should not be re-processed by the intl middleware —
|
// (e.g. /en/settings) should not be re-processed by the intl middleware -
|
||||||
// doing so can trigger rewrite loops when combined with a proxy basePath.
|
// doing so can trigger rewrite loops when combined with a proxy basePath.
|
||||||
const locales = routing.locales as readonly string[];
|
const locales = routing.locales as readonly string[];
|
||||||
const hasLocalePrefix = locales.some(
|
const hasLocalePrefix = locales.some(
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
|
|
||||||
const id = generateAccountId(entry.username, entry.serverUrl);
|
const id = generateAccountId(entry.username, entry.serverUrl);
|
||||||
if (state.accounts.some((a) => a.id === id)) {
|
if (state.accounts.some((a) => a.id === id)) {
|
||||||
// Already exists — update mutable fields and return existing id
|
// Already exists - update mutable fields and return existing id
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
accounts: s.accounts.map((a) =>
|
accounts: s.accounts.map((a) =>
|
||||||
a.id === id
|
a.id === id
|
||||||
|
|||||||
+11
-11
@@ -430,7 +430,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!upgradedToOAuth) {
|
if (!upgradedToOAuth) {
|
||||||
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
|
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
|
||||||
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
|
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
|
||||||
debug.log('auth', 'TOTP re-auth enabled — user will be prompted for fresh codes on session expiry');
|
debug.log('auth', 'TOTP re-auth enabled - user will be prompted for fresh codes on session expiry');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,7 +920,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const remainingAccounts = accountStore.accounts;
|
const remainingAccounts = accountStore.accounts;
|
||||||
|
|
||||||
if (remainingAccounts.length > 0 && !wasDemoMode) {
|
if (remainingAccounts.length > 0 && !wasDemoMode) {
|
||||||
// Switch to the next account — this is the one path that stays in-app
|
// Switch to the next account - this is the one path that stays in-app
|
||||||
const nextAccount = remainingAccounts[0];
|
const nextAccount = remainingAccounts[0];
|
||||||
clearAllStores();
|
clearAllStores();
|
||||||
|
|
||||||
@@ -955,7 +955,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}).catch((err) => debug.error('Failed to load identities after switch:', err));
|
}).catch((err) => debug.error('Failed to load identities after switch:', err));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Client not in memory — clear everything and redirect.
|
// Client not in memory - clear everything and redirect.
|
||||||
// Trying to async-restore during logout caused the original bug.
|
// Trying to async-restore during logout caused the original bug.
|
||||||
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
|
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
|
||||||
evictAccount(nextAccount.id);
|
evictAccount(nextAccount.id);
|
||||||
@@ -971,12 +971,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No accounts remaining (or demo mode) — full logout + redirect
|
// No accounts remaining (or demo mode) - full logout + redirect
|
||||||
performFullLogout(set);
|
performFullLogout(set);
|
||||||
|
|
||||||
notifyParent('sso:logout');
|
notifyParent('sso:logout');
|
||||||
|
|
||||||
// Background cookie/token cleanup — keepalive ensures completion during navigation
|
// Background cookie/token cleanup - keepalive ensures completion during navigation
|
||||||
if (!wasDemoMode) {
|
if (!wasDemoMode) {
|
||||||
apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
|
apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
if (wasOAuth) {
|
if (wasOAuth) {
|
||||||
@@ -984,7 +984,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login — this is synchronous and happens AFTER all state is cleared
|
// Redirect to login - this is synchronous and happens AFTER all state is cleared
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1039,7 +1039,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
let targetRestoreRateLimited = false;
|
let targetRestoreRateLimited = false;
|
||||||
|
|
||||||
if (!targetClient) {
|
if (!targetClient) {
|
||||||
// Client not connected — try to restore
|
// Client not connected - try to restore
|
||||||
try {
|
try {
|
||||||
if (targetAccount.authMode === 'oauth') {
|
if (targetAccount.authMode === 'oauth') {
|
||||||
const res = await apiFetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
|
const res = await apiFetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
|
||||||
@@ -1105,7 +1105,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cannot restore — remove the stale account and redirect to login
|
// Cannot restore - remove the stale account and redirect to login
|
||||||
evictAccount(accountId);
|
evictAccount(accountId);
|
||||||
accountStore.removeAccount(accountId);
|
accountStore.removeAccount(accountId);
|
||||||
apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
|
apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
|
||||||
@@ -1239,7 +1239,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error(`Session cookie missing: ${res.status}`);
|
throw new Error(`Session cookie missing: ${res.status}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Basic auth without rememberMe — can't restore
|
// Basic auth without rememberMe - can't restore
|
||||||
throw new Error('No saved session');
|
throw new Error('No saved session');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1523,7 +1523,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
||||||
set({ identities, primaryIdentity });
|
set({ identities, primaryIdentity });
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — background sync should not surface errors to the user
|
// Silently fail - background sync should not surface errors to the user
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1538,7 +1538,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
// Don't persist unauthenticated state — prevents resurrecting stale sessions
|
// Don't persist unauthenticated state - prevents resurrecting stale sessions
|
||||||
if (!state.isAuthenticated) return {};
|
if (!state.isAuthenticated) return {};
|
||||||
return {
|
return {
|
||||||
serverUrl: state.serverUrl,
|
serverUrl: state.serverUrl,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user