fix: standardize punctuation

This commit is contained in:
Linus Rath
2026-04-16 19:07:42 +02:00
parent 6b57118add
commit 8bdadc7ba3
107 changed files with 452 additions and 448 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+63 -63
View File
@@ -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
+2 -2
View File
@@ -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) => {
+2 -2
View File
@@ -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 ? (
+3 -3
View File
@@ -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 = () => {
+1 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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
View File
@@ -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">
+1 -1
View File
@@ -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>
))} ))}
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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 }
*/ */
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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 }
*/ */
+4 -4
View File
@@ -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) {
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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) {
+1 -1
View File
@@ -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.
*/ */
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 };
+4 -4
View File
@@ -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">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p> <p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)} )}
@@ -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>
+7 -7
View File
@@ -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>
)} )}
+1 -1
View File
@@ -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) {
+18 -18
View File
@@ -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 + &nbsp;) treat these as effectively empty. // boilerplate (CSS + &nbsp;) - 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",
+1 -1
View File
@@ -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;
} }
+2 -2
View File
@@ -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)}
+1 -1
View File
@@ -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));
} }
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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…' },
]; ];
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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`;
+2 -2
View File
@@ -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 },
]; ];
+2 -2
View File
@@ -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();
} }
} }
+2 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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`
); );
} }
}) })
+8 -8
View File
@@ -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',
+6 -6
View File
@@ -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
+9 -9
View File
@@ -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' }),
]; ];
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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>;
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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 })
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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();
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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
} }
+4 -4
View File
@@ -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,
+4 -4
View File
@@ -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
View File
@@ -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: [] };
} }
+1 -1
View File
@@ -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
} }
} }
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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],
}), }),
+1 -1
View File
@@ -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) {
+5 -5
View File
@@ -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');
} }
} }
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+6 -6
View File
@@ -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."
}, },
+8 -8
View File
@@ -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."
}, },
+6 -6
View File
@@ -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."
}, },
+7 -7
View File
@@ -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."
}, },
+7 -7
View File
@@ -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."
}, },
+4 -4
View File
@@ -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": "ファイルストレージ",
+6 -6
View File
@@ -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."
}, },
+7 -7
View File
@@ -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."
}, },
+7 -7
View File
@@ -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."
}, },
+7 -7
View File
@@ -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."
}, },
+8 -8
View File
@@ -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": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства."
}, },
+7 -7
View File
@@ -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": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця."
}, },
+2 -2
View File
@@ -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
View File
@@ -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": {
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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
View File
@@ -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