Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869ee07ebc | ||
|
|
2ad2bb1e09 | ||
|
|
23bc31c661 | ||
|
|
a2f76037a1 | ||
|
|
9571f2e185 | ||
|
|
887b9c728c | ||
|
|
8c21f462c2 | ||
|
|
5f3d2d3e4a | ||
|
|
4bce80b8ba | ||
|
|
1d09f5a623 | ||
|
|
2c513129f2 | ||
|
|
b3dc2e32b8 | ||
|
|
b0640c9ecc | ||
|
|
2d7e24b513 | ||
|
|
5b30bacf10 | ||
|
|
fe937403f3 | ||
|
|
876ea370e4 | ||
|
|
1dcdeeae86 | ||
|
|
01302a775c | ||
|
|
76d78ae756 | ||
|
|
51745ea03d | ||
|
|
c44a9ce6e0 | ||
|
|
7fa65796f0 | ||
|
|
d09df7e8a3 | ||
|
|
090399a308 | ||
|
|
c31a58af1a | ||
|
|
65aabb943c | ||
|
|
9c8739c4bb | ||
|
|
48f72be209 | ||
|
|
92fb0c63e9 | ||
|
|
55596556ef | ||
|
|
abd63d124f | ||
|
|
562080b7a3 | ||
|
|
41c9f4926c | ||
|
|
e7e78072d4 | ||
|
|
cd363b4840 | ||
|
|
3a350c14a6 | ||
|
|
b0765bf085 | ||
|
|
9225ba0790 | ||
|
|
3b36738192 | ||
|
|
3edd35ab57 | ||
|
|
a86a96e390 | ||
|
|
5f464d4ee2 |
+21
-4
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Admin Dashboard Data
|
# Admin Dashboard Data
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Directory for admin dashboard state: config overrides, admin password hash,
|
# Admin data is split across two directories so the config volume can be
|
||||||
# installed plugins/themes, and audit logs (default: ./data/admin).
|
# mounted read-only after the setup wizard completes (see issue #226).
|
||||||
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
#
|
||||||
# volume there (see docker-compose.yml).
|
# Config dir - operator-authored state. Holds config.json, policy.json,
|
||||||
|
# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
|
||||||
|
# branding uploads. Safe to mount read-only after setup.
|
||||||
|
# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
|
||||||
|
# ADMIN_CONFIG_DIR=./data/admin
|
||||||
|
#
|
||||||
|
# State dir - runtime mutations. Holds admin-state.json (login timestamps),
|
||||||
|
# audit.log, and the bootstrap setup token. Always read-write.
|
||||||
|
# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
|
||||||
|
# is set, for back-compat with single-volume installs)
|
||||||
|
# ADMIN_STATE_DIR=./data/admin-state
|
||||||
|
#
|
||||||
|
# Set to "true" to enforce read-only mode at the application layer (cleaner
|
||||||
|
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
|
||||||
|
# ADMIN_CONFIG_READONLY=true
|
||||||
|
#
|
||||||
|
# Legacy: a single dir containing both config and state. Honoured if neither
|
||||||
|
# of the split variables is set. New installs should use the split vars.
|
||||||
# ADMIN_DATA_DIR=./data/admin
|
# ADMIN_DATA_DIR=./data/admin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+1
-1
@@ -49,4 +49,4 @@ next-env.d.ts
|
|||||||
/local-data/
|
/local-data/
|
||||||
|
|
||||||
# Sibling repos
|
# Sibling repos
|
||||||
/repos/
|
/repos/
|
||||||
|
|||||||
@@ -1,5 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.6.4 (2026-05-11)
|
||||||
|
|
||||||
|
### Web Setup Wizard
|
||||||
|
|
||||||
|
First-launch web setup wizard. New installs no longer need to hand-edit `.env.local` - point a browser at the container and the wizard probes the JMAP server(s), configures OAuth/OIDC, generates the session secret, accepts branding uploads, and provisions the initial admin password. Admin storage is now split into `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps); the legacy `ADMIN_DATA_DIR` keeps working for existing installs.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Setup**: Web setup wizard with multi-step flow: Server, Auth, Security, Logging, Branding, Review, Admin
|
||||||
|
- **Setup**: Admin config/state directory split with optional `ADMIN_CONFIG_READONLY` for immutable deployments (#226)
|
||||||
|
- **Setup**: File uploads on the wizard branding step
|
||||||
|
- **Setup**: Redesigned review step with grouped summary and an advanced toggle for the full config
|
||||||
|
- **Setup**: Require explicit confirmation when JMAP probe finds no session
|
||||||
|
- **Mail**: Drag attachments out of the viewer to the local file system (#267)
|
||||||
|
- **Mail**: Reading Pane at Bottom mail layout (#262)
|
||||||
|
- **Mail**: Configurable signature position - above or below quoted text (#266)
|
||||||
|
- **Mail**: Signature position is now searchable from the email behavior settings
|
||||||
|
- **Mail**: Show avatar in Focused list for compact density and above
|
||||||
|
- **Mail**: Align Focused list preview with other layout previews
|
||||||
|
- **Compose**: From-header override in the composer with catch-all auto-reply, replies to an alias on a domain you own pre-fill the alias as the sender even when it isn't a configured identity (#246)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Mail**: Prefetch initial email data on login
|
||||||
|
- **Auth**: Parallelize login round-trips and drop redundant JMAP re-verify
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Skip upstream JMAP reverify for trusted URLs (#237)
|
||||||
|
- **Auth**: Show account identity in the switcher header instead of the sending alias
|
||||||
|
- **Compose**: Fall back to the primary identity signature on reply
|
||||||
|
- **Setup**: Drop redundant first-login banner about removing `ADMIN_PASSWORD` (#222)
|
||||||
|
- **UI**: Consistent notice cards for server probe results
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Add missing translation keys across 15 locales
|
||||||
|
|
||||||
|
## 1.6.3 (2026-05-08)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Lift 5-account cap on HTTP/2
|
||||||
|
- **Mail**: Import `.eml` files via folder right-click menu
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Trim leading whitespace from email list preview
|
||||||
|
- **Mail**: Fall back when only the truncation indicator remains in email preview
|
||||||
|
- **Mail**: Hide files/contacts nav items when JMAP server lacks support
|
||||||
|
- **Viewer**: Preserve emoji colors in dark mode
|
||||||
|
- **Viewer**: Prevent white-on-white in dark mode for nested `bgcolor` containers
|
||||||
|
- **Viewer**: Render plain-text-only emails as text, not HTML
|
||||||
|
- **Viewer**: Render HTML-only emails and redesign external content prompt
|
||||||
|
- **Viewer**: Pad Word/Outlook HTML email rendering
|
||||||
|
- **Compose**: Redesign quick reply to match sender/banner layout
|
||||||
|
- **Compose**: Disable StarterKit's bundled link/underline to avoid duplicate extensions
|
||||||
|
- **Sharing**: Request `shareWith` explicitly so calendar/address book shares survive a re-login (#257)
|
||||||
|
- **UI**: Strip leading punctuation when computing avatar initials
|
||||||
|
- **Mobile**: Hide email hover actions
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Add missing translation keys across 15 locales
|
||||||
|
|
||||||
## 1.6.2 (2026-05-06)
|
## 1.6.2 (2026-05-06)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
+23
-12
@@ -2,19 +2,23 @@
|
|||||||
|
|
||||||
## Mail
|
## Mail
|
||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
- Unified mailbox view across all connected accounts
|
- Unified mailbox view across all connected accounts
|
||||||
- Draft auto-save with identity preservation
|
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||||
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||||
|
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
|
||||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
- Batch operations – multi-select, archive, delete, move, tag
|
- Batch operations – multi-select, archive, delete, move, tag
|
||||||
- Archive modes – direct, by year, or by month
|
- Archive modes – direct, by year, or by month
|
||||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
- Multi-tag support with color labels, reordering, and drag-and-drop 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 plus prefetching of initial email data on login
|
||||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||||
- Plain-text composer mode and Reply-To support
|
- Plain-text composer mode and Reply-To support
|
||||||
|
- Configurable signature position (above or below quoted text) per identity
|
||||||
|
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
|
||||||
|
- `.eml` file import via folder right-click menu
|
||||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||||
- Folder management with icon picker, subfolders, and sidebar counts
|
- Folder management with icon picker, subfolders, and sidebar counts
|
||||||
- Print directly from the viewer
|
- Print directly from the viewer
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
- Three-pane layout with resizable columns
|
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||||
- Dark and light themes with intelligent email color transformation
|
- Dark and light themes with intelligent email color transformation
|
||||||
- Responsive desktop, tablet, and mobile layouts
|
- Responsive desktop, tablet, and mobile layouts
|
||||||
- Full keyboard navigation
|
- Full keyboard navigation
|
||||||
@@ -100,25 +104,32 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
|
|
||||||
## Identity & Multi-Account
|
## Identity & Multi-Account
|
||||||
|
|
||||||
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
|
||||||
- Account switcher with connection status and default account selection
|
- Account switcher with connection status and default account selection
|
||||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||||
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
- Configurable signature position (above or below quoted text)
|
||||||
|
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||||
- Shared folders across accounts
|
- Shared folders across accounts
|
||||||
|
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
## Admin & Extensibility
|
## Admin & Extensibility
|
||||||
|
|
||||||
- Stalwart admin dashboard with dedicated policy sections
|
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||||
|
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||||
|
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
|
||||||
|
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||||
- Bundled plugins including Jitsi Meet calendar integration
|
- Bundled plugins including Jitsi Meet calendar integration
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
||||||
- Automatic update check with server-side logging of new releases
|
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||||
- Structured logging (`text` or `json`) with category-based levels
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
|
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
||||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||||
|
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||||
- Demo mode with fixture data – no mail server required
|
- Demo mode with fixture data – no mail server required
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
[](https://grafana.external.bulwarkmail.org/)
|
||||||
|
|
||||||
@@ -20,6 +20,29 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Installer
|
||||||
|
|
||||||
|
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
|
||||||
|
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
Point a browser at the running container and the wizard guides you through:
|
||||||
|
|
||||||
|
- **Server** – probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle
|
||||||
|
- **Auth** – OAuth2 / OIDC discovery and validation, or basic-auth fallback
|
||||||
|
- **Security** – generate or paste a `SESSION_SECRET`, opt into settings sync
|
||||||
|
- **Logging** – text or JSON, level
|
||||||
|
- **Branding** – upload favicon, app logos, login logos, and company / legal URLs
|
||||||
|
- **Review** – grouped summary with an advanced toggle for the full config
|
||||||
|
- **Admin** – set the initial admin password and optionally drop a `.config-locked` marker so the config volume can be remounted read-only
|
||||||
|
|
||||||
|
The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JMAP_SERVER_URL` in the environment skips the wizard and uses env-managed configuration instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
@@ -63,7 +86,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos
|
|||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||||
|
|
||||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
@@ -74,28 +97,25 @@ Full feature list: **[FEATURES.md](FEATURES.md)**.
|
|||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
|
||||||
-e JMAP_SERVER_URL=https://mail.example.com \
|
|
||||||
ghcr.io/bulwarkmail/webmail:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with Docker Compose:
|
Or with Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
|
||||||
# Edit .env.local – set JMAP_SERVER_URL
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/bulwarkmail/webmail.git
|
git clone https://github.com/bulwarkmail/webmail.git
|
||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
|
||||||
# Edit .env.local – set JMAP_SERVER_URL
|
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
|
# Then open http://localhost:3000 to run the setup wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
@@ -108,13 +128,13 @@ npm run lint
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
|
||||||
|
|
||||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Required
|
# Optional – overrides whatever the wizard writes
|
||||||
JMAP_SERVER_URL=https://mail.example.com
|
JMAP_SERVER_URL=https://mail.example.com
|
||||||
|
|
||||||
# Optional
|
|
||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -218,6 +238,19 @@ LOG_LEVEL=info # error | warn | info | debug
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Admin data directories</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/
|
||||||
|
ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token
|
||||||
|
ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer
|
||||||
|
```
|
||||||
|
|
||||||
|
The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Subpath / reverse proxy mount</summary>
|
<summary>Subpath / reverse proxy mount</summary>
|
||||||
|
|
||||||
|
|||||||
+127
-49
@@ -53,6 +53,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
|||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
|
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -202,7 +203,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
|
||||||
const {
|
const {
|
||||||
emails,
|
emails,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
@@ -636,6 +637,7 @@ export default function Home() {
|
|||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
|
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
|
||||||
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
|
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
|
||||||
|
if (parsed.emailListHeight) setEmailListHeight(parsed.emailListHeight);
|
||||||
}
|
}
|
||||||
} catch { /* ignore parse errors */ }
|
} catch { /* ignore parse errors */ }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -649,7 +651,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||||
|
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
|
||||||
|
// populates mailboxes before this effect first runs, so on the post-login
|
||||||
|
// path this block is a no-op.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -657,18 +662,14 @@ export default function Home() {
|
|||||||
|
|
||||||
const loadData = async (attempt = 1) => {
|
const loadData = async (attempt = 1) => {
|
||||||
try {
|
try {
|
||||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchMailboxes(client),
|
fetchMailboxes(client),
|
||||||
fetchQuota(client)
|
fetchQuota(client)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get the selected mailbox (should be inbox by default)
|
|
||||||
const state = useEmailStore.getState();
|
const state = useEmailStore.getState();
|
||||||
const selectedMailboxId = state.selectedMailbox;
|
const selectedMailboxId = state.selectedMailbox;
|
||||||
|
|
||||||
// On first login the server may still be provisioning mailboxes.
|
|
||||||
// Retry a few times with back-off before giving up.
|
|
||||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||||
const delay = Math.min(1000 * attempt, 5000);
|
const delay = Math.min(1000 * attempt, 5000);
|
||||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||||
@@ -676,34 +677,13 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch emails for the selected mailbox
|
|
||||||
if (selectedMailboxId) {
|
if (selectedMailboxId) {
|
||||||
await fetchEmails(client, selectedMailboxId);
|
await fetchEmails(client, selectedMailboxId);
|
||||||
} else {
|
} else {
|
||||||
await fetchEmails(client);
|
await fetchEmails(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch tag counts
|
|
||||||
fetchTagCounts(client);
|
fetchTagCounts(client);
|
||||||
|
|
||||||
// Setup push notifications after successful data load
|
|
||||||
try {
|
|
||||||
// Register state change callback
|
|
||||||
client.onStateChange((change) => handleStateChange(change, client));
|
|
||||||
|
|
||||||
// Start receiving push notifications
|
|
||||||
const pushEnabled = client.setupPushNotifications();
|
|
||||||
|
|
||||||
if (pushEnabled) {
|
|
||||||
setPushConnected(true);
|
|
||||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
|
||||||
} else {
|
|
||||||
debug.log('push', '[Push] Push notifications not available on this server');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Push notifications are optional - don't break the app if they fail
|
|
||||||
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading email data:', error);
|
console.error('Error loading email data:', error);
|
||||||
}
|
}
|
||||||
@@ -713,17 +693,33 @@ export default function Home() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
client.closePushNotifications();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
|
||||||
|
|
||||||
// Cleanup push notifications on unmount
|
// Push notifications: set up once per client and tear down when the client
|
||||||
return () => {
|
// goes away (logout or account switch). Kept separate from the fetch effect
|
||||||
if (client) {
|
// above so it still runs when data was prefetched at login time.
|
||||||
client.closePushNotifications();
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.onStateChange((change) => handleStateChange(change, client));
|
||||||
|
const pushEnabled = client.setupPushNotifications();
|
||||||
|
if (pushEnabled) {
|
||||||
|
setPushConnected(true);
|
||||||
|
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||||
|
} else {
|
||||||
|
debug.log('push', '[Push] Push notifications not available on this server');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.closePushNotifications();
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
}, [isAuthenticated, client, handleStateChange, setPushConnected]);
|
||||||
|
|
||||||
// Keep unified mailbox counts in sync when the feature is enabled and more
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
// than one account is connected. Runs whenever the set of connected accounts
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
@@ -875,6 +871,7 @@ export default function Home() {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
|
envelopeMailFrom?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
@@ -885,7 +882,7 @@ export default function Home() {
|
|||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const originalEmailId = selectedEmail?.id;
|
const originalEmailId = selectedEmail?.id;
|
||||||
|
|
||||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
|
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.envelopeMailFrom);
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
|
|
||||||
// Mark the original email with $answered or $forwarded keyword
|
// Mark the original email with $answered or $forwarded keyword
|
||||||
@@ -1504,6 +1501,43 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportEmailFromContextMenu = (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
const targetMailboxId = mailbox.originalId || mailbox.id;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.eml,message/rfc822';
|
||||||
|
input.multiple = true;
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
|
||||||
|
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
|
||||||
|
imported++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imported > 0) {
|
||||||
|
toast.success(t('notifications.import_email_success'));
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
toast.error(t('notifications.import_email_error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefreshMailboxes = async () => {
|
const handleRefreshMailboxes = async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
try {
|
try {
|
||||||
@@ -1604,9 +1638,28 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const primaryIdentity = identities[0];
|
const primaryIdentity = identities[0];
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity;
|
||||||
|
|
||||||
// Append signature from the primary identity
|
// Decide the sending identity and (for domain-catch-all) an optional
|
||||||
const finalBody = appendPlainTextSignature(body, primaryIdentity);
|
// header From override that matches the address the message was sent to.
|
||||||
|
// When the setting is off, fall through to primary-identity behavior.
|
||||||
|
const resolved = autoSelectReplyIdentity
|
||||||
|
? resolveReplyFrom(identities, {
|
||||||
|
to: selectedEmail.to,
|
||||||
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const sendingIdentity = resolved
|
||||||
|
? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity)
|
||||||
|
: primaryIdentity;
|
||||||
|
const headerFromEmail = resolved?.overrideEmail || sendingIdentity?.email;
|
||||||
|
const headerFromName = resolved?.overrideName || sendingIdentity?.name || undefined;
|
||||||
|
const envelopeMailFrom = resolved?.overrideEmail ? sendingIdentity?.email : undefined;
|
||||||
|
|
||||||
|
// Append signature from the sending identity (fall back to primary
|
||||||
|
// when the reply-from lives on the same identity but a different alias).
|
||||||
|
const finalBody = appendPlainTextSignature(body, sendingIdentity);
|
||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
|
|
||||||
@@ -1624,14 +1677,15 @@ export default function Home() {
|
|||||||
finalBody,
|
finalBody,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
primaryIdentity?.id,
|
sendingIdentity?.id,
|
||||||
primaryIdentity?.email,
|
headerFromEmail,
|
||||||
undefined,
|
undefined,
|
||||||
primaryIdentity?.name || undefined,
|
headerFromName,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
threading?.inReplyTo,
|
threading?.inReplyTo,
|
||||||
threading?.references,
|
threading?.references,
|
||||||
|
envelopeMailFrom,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark the original email as answered
|
// Mark the original email as answered
|
||||||
@@ -1660,9 +1714,11 @@ export default function Home() {
|
|||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||||
|
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
const handleEmailSelect = async (email: { id: string }) => {
|
const handleEmailSelect = async (email: { id: string }) => {
|
||||||
@@ -1894,6 +1950,7 @@ export default function Home() {
|
|||||||
onCreateFolder={handleCreateFolderFromContextMenu}
|
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||||
onRenameFolder={handleRenameFolderFromContextMenu}
|
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||||
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||||
|
onImportEmail={handleImportEmailFromContextMenu}
|
||||||
onRefreshMailboxes={handleRefreshMailboxes}
|
onRefreshMailboxes={handleRefreshMailboxes}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
@@ -1920,21 +1977,30 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
|
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
|
||||||
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
|
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col h-full bg-background border-r border-border",
|
"relative flex flex-col bg-background",
|
||||||
|
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
|
||||||
// Mobile: full width, hidden when viewing email
|
// Mobile: full width, hidden when viewing email
|
||||||
"max-md:flex-1 max-md:border-r-0",
|
"max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
|
||||||
isMobile && activeView !== "list" && "max-md:hidden",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
|
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
|
||||||
"md:shadow-sm",
|
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
|
||||||
|
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
|
||||||
|
!isHorizontalMailLayout && "md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
|
style={
|
||||||
|
isMobile
|
||||||
|
? undefined
|
||||||
|
: isHorizontalMailLayout
|
||||||
|
? (!shouldHideHorizontalViewerPane ? { height: emailListHeight } : undefined)
|
||||||
|
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -2243,7 +2309,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -2251,17 +2317,29 @@ export default function Home() {
|
|||||||
onDoubleClick={resetEmailListWidth}
|
onDoubleClick={resetEmailListWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && (
|
||||||
|
<ResizeHandle
|
||||||
|
orientation="horizontal"
|
||||||
|
onResizeStart={() => { dragStartWidth.current = emailListHeight; setIsResizing(true); }}
|
||||||
|
onResize={(delta) => setEmailListHeight(dragStartWidth.current + delta)}
|
||||||
|
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
|
||||||
|
onDoubleClick={resetEmailListHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
|
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full bg-background flex-1 min-w-0",
|
"flex flex-col bg-background flex-1 min-w-0",
|
||||||
|
isHorizontalMailLayout ? "min-h-0" : "h-full",
|
||||||
// Mobile: full screen overlay when active
|
// Mobile: full screen overlay when active
|
||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
|
"max-md:h-full",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: relative
|
// Tablet/Desktop: relative
|
||||||
"md:relative",
|
"md:relative",
|
||||||
shouldHideViewerPane && "md:hidden"
|
shouldHideViewerPane && "md:hidden",
|
||||||
|
shouldHideHorizontalViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane */}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.email_behavior.attachment_reminder',
|
'settings.email_behavior.attachment_reminder',
|
||||||
'settings.email_behavior.auto_select_reply_identity',
|
'settings.email_behavior.auto_select_reply_identity',
|
||||||
'settings.email_behavior.default_mail_program',
|
'settings.email_behavior.default_mail_program',
|
||||||
|
'settings.email_behavior.signature_position',
|
||||||
'settings.email_behavior.sub_address_delimiter',
|
'settings.email_behavior.sub_address_delimiter',
|
||||||
],
|
],
|
||||||
identities: ['settings.identities'],
|
identities: ['settings.identities'],
|
||||||
|
|||||||
@@ -119,18 +119,6 @@ export function DashboardTab() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{status && !status.lastLogin && (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-warning">First login detected</p>
|
|
||||||
<p className="text-sm text-warning/80 mt-0.5">
|
|
||||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
|
|||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
{logoUrl ? (
|
||||||
{logoUrl ? (
|
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
|
||||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
) : (
|
||||||
) : (
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
<Shield className="w-6 h-6 text-primary" />
|
<Shield className="w-6 h-6 text-primary" />
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
|
||||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
function getBrandingDir(): string {
|
||||||
|
return path.join(getConfigDir(), 'branding');
|
||||||
|
}
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
'.svg': 'image/svg+xml',
|
'.svg': 'image/svg+xml',
|
||||||
@@ -38,11 +41,11 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(BRANDING_DIR, safe);
|
const filePath = path.join(getBrandingDir(), safe);
|
||||||
|
|
||||||
// Ensure resolved path is still within BRANDING_DIR
|
// Ensure resolved path is still within getBrandingDir()
|
||||||
const resolved = path.resolve(filePath);
|
const resolved = path.resolve(filePath);
|
||||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
|
||||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
function getBrandingDir(): string {
|
||||||
|
return path.join(getConfigDir(), 'branding');
|
||||||
|
}
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
const ALLOWED_MIME_TYPES = new Set([
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
'image/svg+xml',
|
'image/svg+xml',
|
||||||
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
const ext = extMap[file.type] || '.png';
|
const ext = extMap[file.type] || '.png';
|
||||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
const filePath = path.join(BRANDING_DIR, safeName);
|
const filePath = path.join(getBrandingDir(), safeName);
|
||||||
|
|
||||||
// Ensure branding directory exists
|
// Ensure branding directory exists
|
||||||
if (!existsSync(BRANDING_DIR)) {
|
if (!existsSync(getBrandingDir())) {
|
||||||
await mkdir(BRANDING_DIR, { recursive: true });
|
await mkdir(getBrandingDir(), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file to disk
|
// Write file to disk
|
||||||
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||||
let removed = false;
|
let removed = false;
|
||||||
for (const ext of possibleExts) {
|
for (const ext of possibleExts) {
|
||||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
removed = true;
|
removed = true;
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { logger } from '@/lib/logger';
|
|||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import {
|
||||||
|
JmapAuthVerificationError,
|
||||||
|
normalizeJmapServerUrl,
|
||||||
|
validateProxyAuthHeader,
|
||||||
|
verifyJmapAuth,
|
||||||
|
} from '@/lib/auth/verify-jmap-auth';
|
||||||
import {
|
import {
|
||||||
clearStalwartAuthContextInStore,
|
clearStalwartAuthContextInStore,
|
||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
@@ -13,6 +18,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
|||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -23,7 +29,7 @@ function getSlot(request: NextRequest): number {
|
|||||||
const raw = request.nextUrl.searchParams.get('slot');
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
if (raw === null) return 0;
|
if (raw === null) return 0;
|
||||||
const slot = parseInt(raw, 10);
|
const slot = parseInt(raw, 10);
|
||||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +76,16 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
// Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie
|
||||||
|
// we write here is only ever consumed for requests on behalf of this same
|
||||||
|
// user, so bogus credentials would just yield 401s downstream rather than
|
||||||
|
// privilege escalation. Untrusted custom endpoints still verify upstream.
|
||||||
|
const normalizedServerUrl = upstreamTrusted
|
||||||
|
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
||||||
|
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||||
const token = encryptSession(normalizedServerUrl, username, password);
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
@@ -185,8 +197,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
if (all) {
|
if (all) {
|
||||||
// Delete all session cookies (slots 0-4)
|
// Delete all session cookies across every slot.
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
|
||||||
cookieStore.delete(sessionCookieName(i));
|
cookieStore.delete(sessionCookieName(i));
|
||||||
clearStalwartAuthContextInStore(cookieStore, i);
|
clearStalwartAuthContextInStore(cookieStore, i);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
if (!hasSessionSecret()) {
|
||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
|
||||||
return bodySlot;
|
return bodySlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
|
|||||||
if (raw === null) return 0;
|
if (raw === null) return 0;
|
||||||
|
|
||||||
const slot = parseInt(raw, 10);
|
const slot = parseInt(raw, 10);
|
||||||
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? 0 : slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -56,7 +57,15 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slot = getSlot(request, bodySlot);
|
const slot = getSlot(request, bodySlot);
|
||||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
|
||||||
|
// just authenticated to JMAP with these credentials, and the cookie we
|
||||||
|
// write here is only ever consumed for requests on behalf of this same
|
||||||
|
// user — a bogus auth header would just yield 401s downstream, not
|
||||||
|
// privilege escalation. For untrusted custom endpoints we still verify
|
||||||
|
// upstream as before.
|
||||||
|
const normalizedServerUrl = upstreamTrusted
|
||||||
|
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
||||||
|
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||||
|
|
||||||
await setStalwartAuthContext(slot, {
|
await setStalwartAuthContext(slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { logger } from '@/lib/logger';
|
|||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
function getSlot(request: NextRequest): number {
|
function getSlot(request: NextRequest): number {
|
||||||
const raw = request.nextUrl.searchParams.get('slot');
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
if (raw === null) return 0;
|
if (raw === null) return 0;
|
||||||
const slot = parseInt(raw, 10);
|
const slot = parseInt(raw, 10);
|
||||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
|
||||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||||
@@ -112,9 +113,9 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
if (all) {
|
if (all) {
|
||||||
// Revoke and delete all refresh token cookies (slots 0-4)
|
// Revoke and delete all refresh token cookies across every slot.
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
|
||||||
const name = refreshTokenCookieName(i);
|
const name = refreshTokenCookieName(i);
|
||||||
const serverCookieName = refreshTokenServerCookieName(i);
|
const serverCookieName = refreshTokenServerCookieName(i);
|
||||||
const token = cookieStore.get(name)?.value;
|
const token = cookieStore.get(name)?.value;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
|||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
|
||||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -35,8 +35,8 @@ export async function GET() {
|
|||||||
oauthOnly,
|
oauthOnly,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
rememberMeEnabled: hasSessionSecret(),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||||
stalwartFeaturesEnabled,
|
stalwartFeaturesEnabled,
|
||||||
devMode: configManager.get<boolean>('devMode', false),
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
|||||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
function classifyError(error: unknown): { message: string; status: number } {
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
@@ -49,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
const flagOn =
|
||||||
|
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
|
||||||
|
configManager.get<boolean>('settingsSyncEnabled', false);
|
||||||
|
return flagOn && hasSessionSecret();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||||
@@ -59,7 +63,7 @@ function normalizeUrl(url: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify identity against session cookies across all account slots.
|
* Verify identity against session cookies across all account slots.
|
||||||
* With multi-account, the requesting account may be on any slot (0-4).
|
* With multi-account, the requesting account may be on any slot.
|
||||||
* Checks both basic-auth session cookies and stalwart auth context cookies
|
* Checks both basic-auth session cookies and stalwart auth context cookies
|
||||||
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
||||||
* Returns true only if a matching cookie is found.
|
* Returns true only if a matching cookie is found.
|
||||||
@@ -68,7 +72,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const normalizedServerUrl = normalizeUrl(serverUrl);
|
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||||
|
|
||||||
for (let slot = 0; slot <= 4; slot++) {
|
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||||
// Check basic-auth session cookie
|
// Check basic-auth session cookie
|
||||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const VALID_SLOTS = new Set([
|
||||||
|
'faviconUrl',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBrandingDir(): string {
|
||||||
|
return path.join(getConfigDir(), 'branding');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/setup/branding — wizard branding upload.
|
||||||
|
*
|
||||||
|
* Multipart form fields:
|
||||||
|
* file — the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||||
|
* slot — which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||||
|
*
|
||||||
|
* Mirrors /api/admin/branding but authenticates via the wizard cookie
|
||||||
|
* instead of admin session — admin auth doesn't exist yet during bootstrap.
|
||||||
|
* Files land in the same directory; the public read endpoint at
|
||||||
|
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
||||||
|
* assets after setup.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertWritable('upload branding asset');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
const slot = formData.get('slot');
|
||||||
|
|
||||||
|
if (!(file instanceof File) || typeof slot !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||||
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
|
|
||||||
|
const dir = getBrandingDir();
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing file for this slot with a different extension so
|
||||||
|
// the wizard doesn't leave orphan files behind on re-upload.
|
||||||
|
for (const otherExt of Object.values(EXT_BY_MIME)) {
|
||||||
|
if (otherExt === ext) continue;
|
||||||
|
const oldPath = path.join(dir, `${slot}${otherExt}`);
|
||||||
|
if (existsSync(oldPath)) {
|
||||||
|
try { await unlink(oldPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const filePath = path.join(dir, safeName);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||||
|
|
||||||
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Wizard branding upload failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/setup/branding — remove an uploaded asset and clear the
|
||||||
|
* config override so the slot falls back to the system default.
|
||||||
|
*
|
||||||
|
* Body: { slot: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertWritable('remove branding asset');
|
||||||
|
const { slot } = (await request.json()) as { slot?: string };
|
||||||
|
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = getBrandingDir();
|
||||||
|
for (const ext of Object.values(EXT_BY_MIME)) {
|
||||||
|
const filePath = path.join(dir, `${slot}${ext}`);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try { await unlink(filePath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.removeAdminOverride(slot);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Wizard branding delete failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { setInitialAdminPassword } from '@/lib/admin/password';
|
||||||
|
import { clearSetupToken } from '@/lib/setup/token';
|
||||||
|
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/setup/finish
|
||||||
|
*
|
||||||
|
* Final wizard step. Validates that required config is in place, hashes the
|
||||||
|
* admin password, marks setup complete, deletes the setup token (which
|
||||||
|
* invalidates the wizard cookie), and optionally drops a `.config-locked`
|
||||||
|
* marker so the operator remembers they intended to mount :ro.
|
||||||
|
*
|
||||||
|
* Body: { adminPassword: string, lockConfig?: boolean }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { adminPassword?: unknown; lockConfig?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminPassword =
|
||||||
|
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
|
||||||
|
if (adminPassword.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Admin password must be at least 8 characters' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockConfig = body?.lockConfig === true;
|
||||||
|
|
||||||
|
// Validate required config is present.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
|
||||||
|
if (!jmapUrl || typeof jmapUrl !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'JMAP server URL is required (run the Server step first)' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Provision the admin account. Aborts cleanly if one already exists
|
||||||
|
// (defence in depth - should be impossible in bootstrap state).
|
||||||
|
const created = await setInitialAdminPassword(adminPassword);
|
||||||
|
if (!created) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Admin account already exists; cannot finish setup again' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Persist setupComplete flag. After this, detectSetupState() flips
|
||||||
|
// to 'configured' and middleware starts 404'ing /setup paths.
|
||||||
|
await configManager.markSetupComplete();
|
||||||
|
|
||||||
|
// 3. Optional advisory lock marker.
|
||||||
|
if (lockConfig) {
|
||||||
|
await ensureConfigDir();
|
||||||
|
await writeFile(
|
||||||
|
getConfigPath('.config-locked'),
|
||||||
|
new Date().toISOString(),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Destroy the setup token. Any other browser holding the cookie is
|
||||||
|
// now unauthenticated.
|
||||||
|
await clearSetupToken();
|
||||||
|
|
||||||
|
await auditLog(
|
||||||
|
'setup.finish',
|
||||||
|
{ lockConfig, jmapServerUrl: jmapUrl },
|
||||||
|
request.headers.get('x-forwarded-for') ?? 'unknown',
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = NextResponse.json({ ok: true, lockConfig });
|
||||||
|
response.cookies.delete(SETUP_COOKIE);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Wizard finish failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isConfigReadOnly } from '@/lib/admin/paths';
|
||||||
|
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/setup/status - public endpoint that returns the wizard state
|
||||||
|
* and (if authenticated) the partial config saved by previous steps. The
|
||||||
|
* wizard polls this on load so a refresh resumes with prior values.
|
||||||
|
*
|
||||||
|
* Sensitive values (OAuth client secret, session secret) are NEVER sent
|
||||||
|
* back to the client - only a `<key>HasValue` boolean. Re-entering them
|
||||||
|
* after refresh is the price of not exposing them.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const state = detectSetupState();
|
||||||
|
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
|
||||||
|
|
||||||
|
let partialConfig: Record<string, unknown> | null = null;
|
||||||
|
if (state === 'bootstrap' && authenticated) {
|
||||||
|
// Only echo back values the operator has actually saved during the
|
||||||
|
// wizard (admin overrides). System defaults must not flow back here,
|
||||||
|
// because the wizard has its own opinionated defaults (e.g. settings
|
||||||
|
// sync on by default) that we'd otherwise stomp.
|
||||||
|
const sources = configManager.getAllWithSources();
|
||||||
|
const safe: Record<string, unknown> = {};
|
||||||
|
for (const [key, info] of Object.entries(sources)) {
|
||||||
|
if (info.source !== 'admin') continue;
|
||||||
|
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||||
|
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
|
||||||
|
} else {
|
||||||
|
safe[key] = info.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partialConfig = safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
authenticated,
|
||||||
|
readOnly: isConfigReadOnly(),
|
||||||
|
partialConfig,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of wizard-friendly step keys to the config keys they update. Each
|
||||||
|
* step's PATCH validates against this allowlist so a compromised wizard
|
||||||
|
* client can't slip in arbitrary config keys.
|
||||||
|
*/
|
||||||
|
const STEP_KEYS: Record<string, string[]> = {
|
||||||
|
server: [
|
||||||
|
'appName',
|
||||||
|
'jmapServerUrl',
|
||||||
|
'stalwartFeaturesEnabled',
|
||||||
|
'jmapServers',
|
||||||
|
'jmapServerAutoPickByDomain',
|
||||||
|
],
|
||||||
|
auth: [
|
||||||
|
'oauthEnabled',
|
||||||
|
'oauthOnly',
|
||||||
|
'oauthClientId',
|
||||||
|
'oauthClientSecret',
|
||||||
|
'oauthIssuerUrl',
|
||||||
|
],
|
||||||
|
security: ['sessionSecret', 'settingsSyncEnabled'],
|
||||||
|
logging: ['logFormat', 'logLevel'],
|
||||||
|
branding: [
|
||||||
|
'faviconUrl',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
'loginCompanyName',
|
||||||
|
'loginImprintUrl',
|
||||||
|
'loginPrivacyPolicyUrl',
|
||||||
|
'loginWebsiteUrl',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/setup/step
|
||||||
|
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
|
||||||
|
*
|
||||||
|
* Persists partial config under the admin override (config.json). Each
|
||||||
|
* step's allowed keys are restricted by STEP_KEYS so the client can only
|
||||||
|
* touch what the corresponding screen owns.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { step?: unknown; values?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = typeof body?.step === 'string' ? body.step : '';
|
||||||
|
const values = body?.values;
|
||||||
|
const allowedKeys = STEP_KEYS[step];
|
||||||
|
if (!allowedKeys) {
|
||||||
|
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
||||||
|
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||||
|
if (!allowedKeys.includes(key)) {
|
||||||
|
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!(key in CONFIG_ENV_MAP)) {
|
||||||
|
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (key === 'jmapServers') {
|
||||||
|
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
|
||||||
|
// before they're persisted. Mirrors the admin config PATCH route.
|
||||||
|
if (value != null && !Array.isArray(value)) {
|
||||||
|
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sanitized = parseJmapServers(value);
|
||||||
|
const incomingCount = Array.isArray(value) ? value.length : 0;
|
||||||
|
if (sanitized.length !== incomingCount) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updates[key] = sanitized;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updates[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Wizard step save failed', {
|
||||||
|
step,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
|
||||||
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
|
||||||
|
* the check_jmap_server() helper in setup.sh: we hit a few common session
|
||||||
|
* endpoints and look for capability strings to confirm the URL is actually
|
||||||
|
* a JMAP server (vs. a generic HTTP 200 page).
|
||||||
|
*
|
||||||
|
* Body: { url: string }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { url?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
|
||||||
|
if (!raw) {
|
||||||
|
return NextResponse.json({ error: 'url required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = raw.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
for (const endpoint of JMAP_ENDPOINTS) {
|
||||||
|
const target = base + endpoint;
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
const res = await fetch(target, {
|
||||||
|
method: 'GET',
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const text = await res.text();
|
||||||
|
if (looksLikeJmapSession(text)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'jmap_detected',
|
||||||
|
endpoint,
|
||||||
|
httpStatus: res.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next endpoint; we'll fall through to a final reachability
|
||||||
|
// check below if none match.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No JMAP session found. Was the server even reachable?
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'HEAD',
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'reachable_no_jmap',
|
||||||
|
httpStatus: res.status,
|
||||||
|
message:
|
||||||
|
'Server responded but no JMAP session was found at standard paths. ' +
|
||||||
|
'This is OK if a reverse proxy routes JMAP separately.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'unreachable',
|
||||||
|
message: error instanceof Error ? error.message : 'Connection failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeJmapSession(body: string): boolean {
|
||||||
|
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { verifySetupToken } from '@/lib/setup/token';
|
||||||
|
import { buildSessionCookieAttributes } from '@/lib/setup/session';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
|
||||||
|
* startup) for a wizard session cookie. After this, subsequent step calls
|
||||||
|
* authenticate via the cookie instead of pasting the token every time.
|
||||||
|
*
|
||||||
|
* Body: { token: string }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { token?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
|
||||||
|
if (!submitted) {
|
||||||
|
return NextResponse.json({ error: 'Token required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifySetupToken(submitted);
|
||||||
|
if (!ok) {
|
||||||
|
// Don't differentiate between "wrong token" and "no token issued" - the
|
||||||
|
// operator either has it from the logs or they don't.
|
||||||
|
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
const attrs = buildSessionCookieAttributes();
|
||||||
|
response.cookies.set(attrs.name, submitted, {
|
||||||
|
httpOnly: attrs.httpOnly,
|
||||||
|
sameSite: attrs.sameSite,
|
||||||
|
secure: attrs.secure,
|
||||||
|
path: attrs.path,
|
||||||
|
maxAge: attrs.maxAge,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
|
||||||
|
}
|
||||||
+1736
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
@@ -55,6 +55,10 @@ export interface ComposerDraftData {
|
|||||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: EmailComposerProps['replyTo'];
|
replyTo?: EmailComposerProps['replyTo'];
|
||||||
draftId: string | null;
|
draftId: string | null;
|
||||||
|
/** When set, overrides the header From: — sent through the selected identity's envelope. */
|
||||||
|
fromOverrideEmail?: string;
|
||||||
|
fromOverrideName?: string;
|
||||||
|
fromOverrideEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmailComposerProps {
|
interface EmailComposerProps {
|
||||||
@@ -69,6 +73,7 @@ interface EmailComposerProps {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
|
envelopeMailFrom?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
@@ -130,6 +135,24 @@ export function EmailComposer({
|
|||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
|
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||||
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
|
const primaryIdentity = identities[0] ?? null;
|
||||||
|
|
||||||
|
// The signature identity used when embedding the signature into the initial
|
||||||
|
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||||
|
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||||
|
// does not exist yet at this point.
|
||||||
|
const initialCurrentIdentityForSig = initialData?.selectedIdentityId
|
||||||
|
? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity
|
||||||
|
: primaryIdentity;
|
||||||
|
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||||
|
? initialCurrentIdentityForSig
|
||||||
|
: primaryIdentity;
|
||||||
|
const shouldEmbedSignatureAboveQuote =
|
||||||
|
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
|
signaturePosition === 'above_quote' &&
|
||||||
|
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
@@ -179,10 +202,18 @@ export function EmailComposer({
|
|||||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||||
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||||
|
|
||||||
|
// When "above quote" is configured, splice signature between the user's
|
||||||
|
// drafting area and the quoted content so it reads naturally as a
|
||||||
|
// closing for the reply body. Send-time append is skipped — see
|
||||||
|
// shouldEmbedSignatureAboveQuote.
|
||||||
|
const signatureBlock = shouldEmbedSignatureAboveQuote
|
||||||
|
? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
if (mode === 'forward') {
|
if (mode === 'forward') {
|
||||||
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||||
}
|
}
|
||||||
return prefix;
|
return prefix;
|
||||||
}
|
}
|
||||||
@@ -194,20 +225,36 @@ export function EmailComposer({
|
|||||||
const from = replyTo.from?.[0];
|
const from = replyTo.from?.[0];
|
||||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
|
||||||
|
// When "above quote" is configured, splice signature between the user's
|
||||||
|
// drafting area and the quoted content so it reads naturally as a closing
|
||||||
|
// for the reply body. Send-time append is skipped — see
|
||||||
|
// shouldEmbedSignatureAboveQuote.
|
||||||
|
const buildEmbeddedSignatureHtml = (): string => {
|
||||||
|
if (!shouldEmbedSignatureAboveQuote) return '';
|
||||||
|
if (initialSignatureIdentity?.htmlSignature) {
|
||||||
|
return `<br><br>-- <br>${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`;
|
||||||
|
}
|
||||||
|
if (initialSignatureIdentity?.textSignature) {
|
||||||
|
return `<br><br>-- <br>${initialSignatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const signatureBlock = buildEmbeddedSignatureHtml();
|
||||||
|
|
||||||
// Build quoted content as HTML
|
// Build quoted content as HTML
|
||||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
const quoteHeader = mode === 'forward'
|
const quoteHeader = mode === 'forward'
|
||||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||||
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyTo.body) {
|
if (replyTo.body) {
|
||||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||||
if (mode === 'forward') {
|
if (mode === 'forward') {
|
||||||
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return prefix;
|
return prefix;
|
||||||
@@ -245,6 +292,9 @@ export function EmailComposer({
|
|||||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
|
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
|
||||||
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
|
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
|
||||||
|
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
||||||
|
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
||||||
|
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
||||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||||
@@ -276,24 +326,33 @@ export function EmailComposer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
|
||||||
const primaryIdentity = identities[0] ?? null;
|
|
||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
// Alias identities often lack a configured signature - fall back to the primary
|
||||||
|
// identity's signature so replies (which auto-select a matching alias) still
|
||||||
|
// populate the user's signature.
|
||||||
|
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||||
|
? currentIdentity
|
||||||
|
: primaryIdentity;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSelectReplyIdentity) return;
|
if (!autoSelectReplyIdentity) return;
|
||||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
const matchedIdentityId = findReplyIdentityId(identities, {
|
const resolved = resolveReplyFrom(identities, {
|
||||||
to: replyTo?.to,
|
to: replyTo?.to,
|
||||||
cc: replyTo?.cc,
|
cc: replyTo?.cc,
|
||||||
bcc: replyTo?.bcc,
|
bcc: replyTo?.bcc,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchedIdentityId) {
|
if (resolved) {
|
||||||
setSelectedIdentityId(matchedIdentityId);
|
setSelectedIdentityId(resolved.identityId);
|
||||||
|
if (resolved.overrideEmail && !fromOverrideEnabled) {
|
||||||
|
setFromOverrideEnabled(true);
|
||||||
|
setFromOverrideEmail(resolved.overrideEmail);
|
||||||
|
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +371,7 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
|
fromOverrideEnabled,
|
||||||
identities,
|
identities,
|
||||||
initialData?.selectedIdentityId,
|
initialData?.selectedIdentityId,
|
||||||
mode,
|
mode,
|
||||||
@@ -322,10 +382,10 @@ export function EmailComposer({
|
|||||||
selectedIdentityId,
|
selectedIdentityId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
|
||||||
: currentIdentity?.textSignature
|
: signatureIdentity?.textSignature
|
||||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
: '';
|
: '';
|
||||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||||
@@ -361,8 +421,8 @@ export function EmailComposer({
|
|||||||
}, [currentSmimeIdentityId]);
|
}, [currentSmimeIdentityId]);
|
||||||
|
|
||||||
// Keep a ref to current state for the unmount save
|
// Keep a ref to current state for the unmount save
|
||||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
|
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
|
||||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
|
||||||
|
|
||||||
// Track initial values for dirty detection (captured once on first render)
|
// Track initial values for dirty detection (captured once on first render)
|
||||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||||
@@ -755,11 +815,17 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// Get the selected identity or primary identity
|
// Get the selected identity or primary identity
|
||||||
// Generate sub-addressed email if tag is set
|
// Generate sub-addressed email if tag is set
|
||||||
const fromEmail = currentIdentity?.email
|
const identityFromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||||
|
? fromOverrideEmail.trim()
|
||||||
|
: identityFromEmail;
|
||||||
|
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||||
|
? (fromOverrideName.trim() || undefined)
|
||||||
|
: (currentIdentity?.name || undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedDraftId = await client.createDraft(
|
const savedDraftId = await client.createDraft(
|
||||||
@@ -772,7 +838,7 @@ export function EmailComposer({
|
|||||||
fromEmail,
|
fromEmail,
|
||||||
draftId || undefined,
|
draftId || undefined,
|
||||||
uploadedAttachments,
|
uploadedAttachments,
|
||||||
currentIdentity?.name || undefined,
|
fromName,
|
||||||
plainTextMode ? undefined : body
|
plainTextMode ? undefined : body
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -945,20 +1011,38 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromEmail = currentIdentity?.email
|
const identityFromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// When the user has typed a From override, that becomes the header From
|
||||||
|
// (and MIME-builder From in the S/MIME path). The identity still drives
|
||||||
|
// the SMTP envelope MAIL FROM — set explicitly so it doesn't mistakenly
|
||||||
|
// default to the override address.
|
||||||
|
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
|
||||||
|
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
|
||||||
|
const fromName = overrideActive
|
||||||
|
? (fromOverrideName.trim() || undefined)
|
||||||
|
: (currentIdentity?.name || undefined);
|
||||||
|
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||||
|
|
||||||
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||||
|
// When "above quote" mode is configured for replies/forwards, the signature
|
||||||
|
// was embedded into the body during init (see getInitialBody) so the
|
||||||
|
// trailing append must be skipped to avoid duplicating it.
|
||||||
|
const signatureAlreadyInBody =
|
||||||
|
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
|
signaturePosition === 'above_quote';
|
||||||
|
|
||||||
// Build HTML signature block (used only in rich text mode)
|
// Build HTML signature block (used only in rich text mode)
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
if (currentIdentity?.htmlSignature) {
|
if (signatureAlreadyInBody) return '';
|
||||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
if (signatureIdentity?.htmlSignature) {
|
||||||
|
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
||||||
}
|
}
|
||||||
if (currentIdentity?.textSignature) {
|
if (signatureIdentity?.textSignature) {
|
||||||
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
return `<br><br>-- <br>${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
@@ -970,8 +1054,8 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// In plain text mode, send text/plain only (no HTML body)
|
// In plain text mode, send text/plain only (no HTML body)
|
||||||
const finalBody = plainTextMode
|
const finalBody = plainTextMode
|
||||||
? appendPlainTextSignature(body, currentIdentity)
|
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity))
|
||||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity));
|
||||||
|
|
||||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||||
const finalHtmlBody = plainTextMode
|
const finalHtmlBody = plainTextMode
|
||||||
@@ -1006,6 +1090,12 @@ export function EmailComposer({
|
|||||||
if (smimeSign_ && !smimeKeyRecord) {
|
if (smimeSign_ && !smimeKeyRecord) {
|
||||||
throw new Error('No S/MIME key bound to this identity');
|
throw new Error('No S/MIME key bound to this identity');
|
||||||
}
|
}
|
||||||
|
// S/MIME binds to the identity's key; sending from an override address
|
||||||
|
// would produce a signature whose Subject differs from the visible
|
||||||
|
// From, which most clients reject or flag. Refuse up front.
|
||||||
|
if (overrideActive) {
|
||||||
|
throw new Error('Cannot use From override with S/MIME — disable one to send.');
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Ensure key is unlocked for signing
|
// 2. Ensure key is unlocked for signing
|
||||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||||
@@ -1150,8 +1240,9 @@ export function EmailComposer({
|
|||||||
htmlBody: outgoing.htmlBody || undefined,
|
htmlBody: outgoing.htmlBody || undefined,
|
||||||
draftId: finalDraftId || undefined,
|
draftId: finalDraftId || undefined,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
fromName: currentIdentity?.name || undefined,
|
fromName,
|
||||||
identityId: outgoing.identityId || currentIdentity?.id,
|
identityId: outgoing.identityId || currentIdentity?.id,
|
||||||
|
envelopeMailFrom,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
references: threadingHeaders?.references,
|
||||||
@@ -1179,7 +1270,7 @@ export function EmailComposer({
|
|||||||
setSubAddressTag("");
|
setSubAddressTag("");
|
||||||
setValidationErrors({});
|
setValidationErrors({});
|
||||||
// Clear ref so unmount effect doesn't re-save
|
// Clear ref so unmount effect doesn't re-save
|
||||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error('Failed to send email:', err);
|
debug.error('Failed to send email:', err);
|
||||||
toast.error(t('send_failed'));
|
toast.error(t('send_failed'));
|
||||||
@@ -1190,7 +1281,7 @@ export function EmailComposer({
|
|||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||||
onClose?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1200,7 +1291,7 @@ export function EmailComposer({
|
|||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
await saveDraft();
|
await saveDraft();
|
||||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||||
onClose?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1212,7 +1303,7 @@ export function EmailComposer({
|
|||||||
if (draftId && onDiscardDraft) {
|
if (draftId && onDiscardDraft) {
|
||||||
onDiscardDraft(draftId);
|
onDiscardDraft(draftId);
|
||||||
}
|
}
|
||||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||||
onClose?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1296,7 +1387,25 @@ export function EmailComposer({
|
|||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
|
||||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
|
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
|
||||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||||
{identities.length > 1 ? (
|
{fromOverrideEnabled ? (
|
||||||
|
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
value={fromOverrideName}
|
||||||
|
onChange={(e) => setFromOverrideName(e.target.value)}
|
||||||
|
placeholder={t('from_override.name_placeholder')}
|
||||||
|
className="h-7 text-sm w-32 md:w-40 shrink-0"
|
||||||
|
aria-label={t('from_override.name_label')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={fromOverrideEmail}
|
||||||
|
onChange={(e) => setFromOverrideEmail(e.target.value)}
|
||||||
|
placeholder={t('from_override.email_placeholder')}
|
||||||
|
type="email"
|
||||||
|
className="h-7 text-sm flex-1 min-w-0 font-mono"
|
||||||
|
aria-label={t('from_override.email_label')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : identities.length > 1 ? (
|
||||||
<select
|
<select
|
||||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||||
@@ -1328,16 +1437,18 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<SubAddressHelper
|
{!fromOverrideEnabled && (
|
||||||
baseEmail={
|
<SubAddressHelper
|
||||||
(selectedIdentityId
|
baseEmail={
|
||||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
(selectedIdentityId
|
||||||
: primaryIdentity?.email) || ''
|
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||||
}
|
: primaryIdentity?.email) || ''
|
||||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
}
|
||||||
onSelectTag={setSubAddressTag}
|
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||||
/>
|
onSelectTag={setSubAddressTag}
|
||||||
{subAddressTag && (
|
/>
|
||||||
|
)}
|
||||||
|
{!fromOverrideEnabled && subAddressTag && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1349,6 +1460,28 @@ export function EmailComposer({
|
|||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (fromOverrideEnabled) {
|
||||||
|
setFromOverrideEnabled(false);
|
||||||
|
} else {
|
||||||
|
setFromOverrideEnabled(true);
|
||||||
|
if (!fromOverrideEmail && currentIdentity?.email) {
|
||||||
|
setFromOverrideEmail(currentIdentity.email);
|
||||||
|
}
|
||||||
|
if (!fromOverrideName && currentIdentity?.name) {
|
||||||
|
setFromOverrideName(currentIdentity.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 px-2 text-xs shrink-0"
|
||||||
|
title={t('from_override.toggle_tooltip')}
|
||||||
|
>
|
||||||
|
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1499,10 +1632,13 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plainTextMode ? (
|
{/* Hide the visual signature preview when the signature has already been
|
||||||
getPlainTextSignature(currentIdentity) ? (
|
embedded into the body above the quote (otherwise it would appear twice). */}
|
||||||
|
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
|
||||||
|
: plainTextMode ? (
|
||||||
|
getPlainTextSignature(signatureIdentity) ? (
|
||||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||||
{'-- \n'}{getPlainTextSignature(currentIdentity)}
|
{'-- \n'}{getPlainTextSignature(signatureIdentity)}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
) : composerSignatureHtml ? (
|
) : composerSignatureHtml ? (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { HoverAction } from "@/stores/settings-store";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
interface EmailHoverActionsProps {
|
interface EmailHoverActionsProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -76,11 +77,13 @@ export function EmailHoverActions({
|
|||||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
||||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const hoverBackgroundClassName = backgroundClassName;
|
const hoverBackgroundClassName = backgroundClassName;
|
||||||
|
|
||||||
|
if (isMobile) return null;
|
||||||
if (hoverActions.length === 0) return null;
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -51,7 +51,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||||
|
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||||
const colorTagIds = getEmailColorTags(email.keywords);
|
const colorTagIds = getEmailColorTags(email.keywords);
|
||||||
@@ -128,8 +129,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
@@ -160,11 +161,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
@@ -295,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: "text-muted-foreground/80"
|
: "text-muted-foreground/80"
|
||||||
)}>
|
)}>
|
||||||
{email.preview || "No preview available"}
|
{trimmedPreview || "No preview available"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function EmailList({
|
|||||||
|
|
||||||
const estimateSize = useCallback(() => {
|
const estimateSize = useCallback(() => {
|
||||||
if (isFocusedMailLayout) {
|
if (isFocusedMailLayout) {
|
||||||
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density];
|
||||||
}
|
}
|
||||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ import { usePluginStore } from "@/stores/plugin-store";
|
|||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||||
|
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -791,6 +793,48 @@ function ContactSidebarPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DraggableAttachmentChipProps {
|
||||||
|
attachment: EffectiveAttachment;
|
||||||
|
client: IJMAPClient | null;
|
||||||
|
enabled: boolean;
|
||||||
|
children: (dragProps: {
|
||||||
|
draggable: boolean;
|
||||||
|
onPointerEnter: () => void;
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
|
||||||
|
const source = useMemo<AttachmentDragSource>(() => ({
|
||||||
|
name: attachment.name || 'download',
|
||||||
|
type: attachment.type || 'application/octet-stream',
|
||||||
|
getBlobUrl: async () => {
|
||||||
|
if (attachment.blobId && client) {
|
||||||
|
try {
|
||||||
|
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attachment.tnefData) {
|
||||||
|
const bytes = attachment.tnefData;
|
||||||
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' }));
|
||||||
|
}
|
||||||
|
if (attachment.decryptedAttachment) {
|
||||||
|
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
|
||||||
|
if (!bytes || bytes.byteLength === 0) return null;
|
||||||
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' }));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}), [attachment, client]);
|
||||||
|
const drag = useAttachmentDrag(source, enabled);
|
||||||
|
return <>{children(drag)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -854,6 +898,7 @@ export function EmailViewer({
|
|||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||||
|
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -2294,13 +2339,24 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
// Per RFC 8621 § 4.1.4, when a message has only one alternative the server
|
||||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
// exposes the same part in both htmlBody and textBody. The shared part may
|
||||||
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
// actually be text/plain (plain-text-only mail) - rendering that as HTML
|
||||||
if (hasTextBody && htmlContent) {
|
// collapses newlines and skips linkification, so route by the part's type.
|
||||||
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
const htmlPart = email.htmlBody[0];
|
||||||
|
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
|
||||||
|
useHtmlVersion = false;
|
||||||
} else {
|
} else {
|
||||||
useHtmlVersion = !!htmlContent;
|
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
||||||
|
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||||
|
const textPartId = email.textBody?.[0]?.partId;
|
||||||
|
const htmlPartId = htmlPart.partId;
|
||||||
|
const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
|
||||||
|
if (hasDistinctTextBody && htmlContent) {
|
||||||
|
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
||||||
|
} else {
|
||||||
|
useHtmlVersion = !!htmlContent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2678,6 +2734,11 @@ export function EmailViewer({
|
|||||||
// Re-invert leaf media elements so they appear normal.
|
// Re-invert leaf media elements so they appear normal.
|
||||||
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
||||||
// double re-inverting images nested inside those containers.
|
// double re-inverting images nested inside those containers.
|
||||||
|
// Nested bgcolor containers must NOT add another invert layer: each filter
|
||||||
|
// toggles the inversion, so an odd number of stacked filters (e.g. body +
|
||||||
|
// outer bgcolor table + inner bgcolor table) produces an inverted result -
|
||||||
|
// i.e. light-on-light. The second rule disables filter on bgcolor-like
|
||||||
|
// elements that are descendants of another bgcolor-like element.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
html { background: #1a1a1a; }
|
html { background: #1a1a1a; }
|
||||||
body { filter: invert(1) hue-rotate(180deg); }
|
body { filter: invert(1) hue-rotate(180deg); }
|
||||||
@@ -2692,13 +2753,28 @@ export function EmailViewer({
|
|||||||
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
|
:where([style*="background-image"], [style*="background:"], [background], [bgcolor])
|
||||||
|
:where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
||||||
|
|
||||||
// Bare HTML emails (no <style>) tend to be plain prose without their own
|
// Bare HTML emails (no <style>) tend to be plain prose without their own
|
||||||
// layout - give them the same padding as plain-text mails (.email-content-text).
|
// layout - give them the same padding as plain-text mails (.email-content-text).
|
||||||
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
|
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
||||||
|
// @page margins (print-only), so they need a fallback body padding too.
|
||||||
|
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
|
||||||
|
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
|
||||||
|
|
||||||
|
// Word emails rely on empty <p class=MsoNormal> </p> spacers for vertical
|
||||||
|
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||||
|
// tighten to match how Outlook/Gmail render the same source.
|
||||||
|
const wordHtmlCSS = isWordHtml ? `
|
||||||
|
body { line-height: 1.15; }
|
||||||
|
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
|
||||||
|
` : '';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -2709,6 +2785,7 @@ export function EmailViewer({
|
|||||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||||
td, th { word-break: break-word; }
|
td, th { word-break: break-word; }
|
||||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
${wordHtmlCSS}
|
||||||
${darkModeCSS}
|
${darkModeCSS}
|
||||||
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
||||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
||||||
@@ -2852,6 +2929,75 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-invert emoji glyphs so they keep their original colors. The
|
||||||
|
// body's invert filter flips colored emoji (yellow smiley → blue,
|
||||||
|
// red heart → cyan, etc.). Wrap each emoji run in a span that
|
||||||
|
// re-inverts. Only act when the ancestor invert depth is odd -
|
||||||
|
// emojis inside a double-inverted bgcolor container already render
|
||||||
|
// at their original colors.
|
||||||
|
let emojiRe: RegExp;
|
||||||
|
try {
|
||||||
|
emojiRe = new RegExp('\\p{RGI_Emoji}', 'gv');
|
||||||
|
} catch {
|
||||||
|
emojiRe = /\p{Extended_Pictographic}(?:\uFE0F)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F)?)*/gu;
|
||||||
|
}
|
||||||
|
const emojiTestRe = /\p{Extended_Pictographic}/u;
|
||||||
|
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'IFRAME']);
|
||||||
|
|
||||||
|
const isOddInvertDepth = (start: Element | null) => {
|
||||||
|
let count = 0;
|
||||||
|
let n: Element | null = start;
|
||||||
|
while (n) {
|
||||||
|
if (n === doc.body) { count++; break; }
|
||||||
|
const cs = win.getComputedStyle(n);
|
||||||
|
if (cs.filter && cs.filter.includes('invert')) count++;
|
||||||
|
n = n.parentElement;
|
||||||
|
}
|
||||||
|
return count % 2 === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
let p = node.parentElement;
|
||||||
|
while (p) {
|
||||||
|
if (SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT;
|
||||||
|
p = p.parentElement;
|
||||||
|
}
|
||||||
|
return emojiTestRe.test(node.nodeValue || '')
|
||||||
|
? NodeFilter.FILTER_ACCEPT
|
||||||
|
: NodeFilter.FILTER_REJECT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiTextNodes: Text[] = [];
|
||||||
|
let cur: Node | null;
|
||||||
|
while ((cur = walker.nextNode())) emojiTextNodes.push(cur as Text);
|
||||||
|
|
||||||
|
emojiTextNodes.forEach((textNode) => {
|
||||||
|
const parent = textNode.parentElement;
|
||||||
|
if (!parent || !isOddInvertDepth(parent)) return;
|
||||||
|
const text = textNode.nodeValue || '';
|
||||||
|
emojiRe.lastIndex = 0;
|
||||||
|
const frag = doc.createDocumentFragment();
|
||||||
|
let lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = emojiRe.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIndex) {
|
||||||
|
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
|
||||||
|
}
|
||||||
|
const span = doc.createElement('span');
|
||||||
|
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
|
||||||
|
span.textContent = m[0];
|
||||||
|
frag.appendChild(span);
|
||||||
|
lastIndex = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (lastIndex === 0) return;
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
parent.replaceChild(frag, textNode);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4303,8 +4449,9 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
||||||
thumbUrl
|
thumbUrl
|
||||||
@@ -4313,6 +4460,10 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<div className="w-full h-16 bg-background/40 flex items-center justify-center overflow-hidden">
|
<div className="w-full h-16 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
@@ -4356,6 +4507,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{effectiveAttachments.length > 2 && (
|
{effectiveAttachments.length > 2 && (
|
||||||
@@ -4376,11 +4529,16 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs text-foreground truncate max-w-[180px]">
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
@@ -4408,6 +4566,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4550,34 +4710,50 @@ export function EmailViewer({
|
|||||||
<div className="flex flex-col gap-3 isolate">
|
<div className="flex flex-col gap-3 isolate">
|
||||||
{/* External Content Controls */}
|
{/* External Content Controls */}
|
||||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||||
<div className="flex items-center gap-3 flex-wrap md:justify-center rounded-md px-3 py-1 bg-muted/50 dark:bg-muted/30">
|
<div className="flex items-start gap-3 py-1">
|
||||||
{externalContentPolicy === 'ask' && (
|
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<button
|
<Image className="w-5 h-5" />
|
||||||
onClick={() => setAllowExternalContent(true)}
|
</div>
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
>
|
<div>
|
||||||
<Image className="w-3.5 h-3.5" />
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{t('load_external_content')}
|
External Content
|
||||||
</button>
|
</div>
|
||||||
)}
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
{email.from?.[0]?.email && (
|
{t('external_content_warning')}
|
||||||
<button
|
</div>
|
||||||
onClick={() => {
|
</div>
|
||||||
const senderEmail = email.from?.[0]?.email;
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
if (senderEmail) {
|
{externalContentPolicy === 'ask' && (
|
||||||
if (trustedSendersAddressBook && client) {
|
<button
|
||||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
onClick={() => setAllowExternalContent(true)}
|
||||||
} else {
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
|
||||||
addTrustedSender(senderEmail);
|
>
|
||||||
}
|
<Image className="w-3.5 h-3.5" />
|
||||||
setAllowExternalContent(true);
|
{t('load_external_content')}
|
||||||
}
|
</button>
|
||||||
}}
|
)}
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
|
{email.from?.[0]?.email && (
|
||||||
>
|
<button
|
||||||
{t('trust_sender')}
|
onClick={() => {
|
||||||
</button>
|
const senderEmail = email.from?.[0]?.email;
|
||||||
)}
|
if (senderEmail) {
|
||||||
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||||
|
} else {
|
||||||
|
addTrustedSender(senderEmail);
|
||||||
|
}
|
||||||
|
setAllowExternalContent(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
{t('trust_sender')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4645,8 +4821,9 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
|
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
|
||||||
thumbUrl
|
thumbUrl
|
||||||
@@ -4655,6 +4832,10 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
@@ -4703,6 +4884,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||||
@@ -4723,11 +4906,16 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-sm text-foreground truncate max-w-[220px]">
|
<span className="text-sm text-foreground truncate max-w-[220px]">
|
||||||
@@ -4755,6 +4943,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4774,8 +4964,9 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
||||||
thumbUrl
|
thumbUrl
|
||||||
@@ -4784,6 +4975,10 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
@@ -4827,6 +5022,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{effectiveAttachments.length > 2 && (
|
{effectiveAttachments.length > 2 && (
|
||||||
@@ -4846,11 +5043,16 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs text-foreground truncate max-w-[180px]">
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
@@ -4878,6 +5080,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4933,18 +5137,17 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
|
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
<div className="flex-shrink-0">
|
||||||
)}>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Avatar
|
<Avatar
|
||||||
name={currentUserName || "You"}
|
name={currentUserName || "You"}
|
||||||
email={currentUserEmail || ""}
|
email={currentUserEmail || ""}
|
||||||
size="sm"
|
size="lg"
|
||||||
|
className="shadow-sm w-10 h-10"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 space-y-3">
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-3">
|
||||||
<textarea
|
<textarea
|
||||||
value={quickReplyText}
|
value={quickReplyText}
|
||||||
onChange={(e) => setQuickReplyText(e.target.value)}
|
onChange={(e) => setQuickReplyText(e.target.value)}
|
||||||
@@ -5024,7 +5227,6 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export function RichTextEditor({
|
|||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: { levels: [1, 2] },
|
heading: { levels: [1, 2] },
|
||||||
|
link: false,
|
||||||
|
underline: false,
|
||||||
}),
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link.configure({
|
Link.configure({
|
||||||
|
|||||||
@@ -331,8 +331,12 @@ function EmailCard({
|
|||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
||||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||||
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
|
||||||
if (hasTextBody && htmlContent) {
|
// in that case there is no real plain-text alternative, so always render the HTML.
|
||||||
|
const textPartId = email.textBody?.[0]?.partId;
|
||||||
|
const htmlPartId = email.htmlBody[0].partId;
|
||||||
|
const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
|
||||||
|
if (hasDistinctTextBody && htmlContent) {
|
||||||
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
||||||
} else {
|
} else {
|
||||||
useHtmlVersion = !!htmlContent;
|
useHtmlVersion = !!htmlContent;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -71,7 +71,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||||
|
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||||
const tagIds = getEmailColorTags(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
@@ -148,8 +149,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
@@ -178,11 +179,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
@@ -316,7 +317,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: "text-muted-foreground/80"
|
: "text-muted-foreground/80"
|
||||||
)}>
|
)}>
|
||||||
{email.preview || "No preview available"}
|
{trimmedPreview || "No preview available"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -366,7 +367,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||||
|
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
@@ -506,8 +508,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
@@ -562,11 +564,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={avatarPerson?.name}
|
name={avatarPerson?.name}
|
||||||
email={avatarPerson?.email}
|
email={avatarPerson?.email}
|
||||||
size="md"
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
@@ -722,7 +724,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: "text-muted-foreground/80"
|
: "text-muted-foreground/80"
|
||||||
)}>
|
)}>
|
||||||
{latestEmail.preview || "No preview available"}
|
{trimmedPreview || "No preview available"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
@@ -49,7 +49,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
|
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (!buttonRef.current) return;
|
if (!buttonRef.current) return;
|
||||||
@@ -115,9 +114,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
setDefaultAccount(accountId);
|
setDefaultAccount(accountId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Display name for the active account
|
// Show the account's own identity, not the preferred sending identity -
|
||||||
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
|
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
||||||
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
|
// the actually logged-in account (info@linusrath.de).
|
||||||
|
const displayName = activeAccount?.displayName || activeAccount?.label || "";
|
||||||
|
const displayEmail = activeAccount?.email || activeAccount?.username || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -220,7 +221,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separator + Add Account */}
|
{/* Separator + Add Account */}
|
||||||
{accounts.length < MAX_ACCOUNTS && (
|
{accounts.length < getMaxAccounts() && (
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={handleAddAccount}
|
onClick={handleAddAccount}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
FolderX,
|
FolderX,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
|
|||||||
onCreateFolder?: () => void;
|
onCreateFolder?: () => void;
|
||||||
onRenameFolder?: (mailboxId: string) => void;
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
|
|||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
onRenameFolder,
|
onRenameFolder,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
|
onImportEmail,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: MailboxContextMenuProps) {
|
}: MailboxContextMenuProps) {
|
||||||
const t = useTranslations("mailbox_context_menu");
|
const t = useTranslations("mailbox_context_menu");
|
||||||
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
|
|||||||
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
|
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
|
||||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||||
|
const canAddItems = mailbox.myRights?.mayAddItems !== false;
|
||||||
|
|
||||||
const fullPath = getMailboxPath(mailbox, mailboxes);
|
const fullPath = getMailboxPath(mailbox, mailboxes);
|
||||||
|
|
||||||
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
|
|||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Upload}
|
||||||
|
label={t("import_email")}
|
||||||
|
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
|
||||||
|
disabled={!onImportEmail || !canAddItems}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={FolderX}
|
icon={FolderX}
|
||||||
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
@@ -168,7 +167,9 @@ export function NavigationRail({
|
|||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const { supportsCalendar } = useCalendarStore();
|
const { supportsCalendar } = useCalendarStore();
|
||||||
const { mailboxes } = useEmailStore();
|
const { mailboxes } = useEmailStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const supportsFiles = client?.supportsFiles() ?? false;
|
||||||
|
const supportsContacts = client?.supportsContacts() ?? false;
|
||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
@@ -252,8 +253,8 @@ export function NavigationRail({
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
|
||||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
|
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||||
@@ -634,7 +635,7 @@ export function NavigationRail({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{accounts.length < MAX_ACCOUNTS && (
|
{accounts.length < getMaxAccounts() && (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/login?mode=add-account` as never)}
|
onClick={() => router.push(`/login?mode=add-account` as never)}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
|
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
|
||||||
|
|||||||
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
|
|||||||
onResize: (delta: number) => void;
|
onResize: (delta: number) => void;
|
||||||
onResizeEnd?: () => void;
|
onResizeEnd?: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
|
orientation?: "vertical" | "horizontal";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEYBOARD_STEP = 10;
|
const KEYBOARD_STEP = 10;
|
||||||
|
|
||||||
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
|
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className }: ResizeHandleProps) {
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
const startX = useRef(0);
|
const startPos = useRef(0);
|
||||||
|
const isHorizontal = orientation === "horizontal";
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
startX.current = e.clientX;
|
startPos.current = isHorizontal ? e.clientY : e.clientX;
|
||||||
document.body.style.cursor = "col-resize";
|
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
|
||||||
document.body.style.userSelect = "none";
|
document.body.style.userSelect = "none";
|
||||||
onResizeStart?.();
|
onResizeStart?.();
|
||||||
}, [onResizeStart]);
|
}, [onResizeStart, isHorizontal]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
let delta = 0;
|
let delta = 0;
|
||||||
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
if (isHorizontal) {
|
||||||
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
|
||||||
else return;
|
else if (e.key === "ArrowDown") delta = KEYBOARD_STEP;
|
||||||
|
else return;
|
||||||
|
} else {
|
||||||
|
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
||||||
|
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
||||||
|
else return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onResize(delta);
|
onResize(delta);
|
||||||
onResizeEnd?.();
|
onResizeEnd?.();
|
||||||
}, [onResize, onResizeEnd]);
|
}, [onResize, onResizeEnd, isHorizontal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isDragging.current) return;
|
if (!isDragging.current) return;
|
||||||
const delta = e.clientX - startX.current;
|
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
|
||||||
onResize(delta);
|
onResize(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
|||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [onResize, onResizeEnd]);
|
}, [onResize, onResizeEnd, isHorizontal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
|
||||||
aria-label="Resize"
|
aria-label="Resize"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
||||||
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
|
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||||
|
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-y-0 -left-1 -right-1" />
|
<div className={cn("absolute", isHorizontal ? "inset-x-0 -top-1 -bottom-1" : "inset-y-0 -left-1 -right-1")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface SidebarProps {
|
|||||||
onCreateFolder?: () => void;
|
onCreateFolder?: () => void;
|
||||||
onRenameFolder?: (mailboxId: string) => void;
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
onRefreshMailboxes?: () => void;
|
onRefreshMailboxes?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -636,6 +637,7 @@ export function Sidebar({
|
|||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
onRenameFolder,
|
onRenameFolder,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
|
onImportEmail,
|
||||||
onRefreshMailboxes,
|
onRefreshMailboxes,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
@@ -1037,6 +1039,7 @@ export function Sidebar({
|
|||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
onRenameFolder={onRenameFolder}
|
onRenameFolder={onRenameFolder}
|
||||||
onDeleteFolder={onDeleteFolder}
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
onImportEmail={onImportEmail}
|
||||||
onRefresh={onRefreshMailboxes}
|
onRefresh={onRefreshMailboxes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function ComposingSettings() {
|
|||||||
attachmentReminderEnabled,
|
attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords,
|
attachmentReminderKeywords,
|
||||||
subAddressDelimiter,
|
subAddressDelimiter,
|
||||||
|
signaturePosition,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
@@ -50,6 +51,17 @@ export function ComposingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
||||||
|
<Select
|
||||||
|
value={signaturePosition}
|
||||||
|
onChange={(value) => updateSetting('signaturePosition', value as 'above_quote' | 'below_quote')}
|
||||||
|
options={[
|
||||||
|
{ value: 'above_quote', label: t('signature_position.above_quote') },
|
||||||
|
{ value: 'below_quote', label: t('signature_position.below_quote') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('sub_address_delimiter.label')}
|
label={t('sub_address_delimiter.label')}
|
||||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const MAIL_LAYOUT_PREVIEW_ROWS = [
|
|||||||
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MAIL_LAYOUT_PREVIEW_ROWS_FOCUS = [
|
||||||
|
...MAIL_LAYOUT_PREVIEW_ROWS,
|
||||||
|
{ sender: 'Sam', subject: 'Lunch?', preview: '', selected: false },
|
||||||
|
{ sender: 'Newsletter', subject: 'Weekly digest', preview: '', selected: false },
|
||||||
|
];
|
||||||
|
|
||||||
function MailLayoutPreview({
|
function MailLayoutPreview({
|
||||||
value,
|
value,
|
||||||
t,
|
t,
|
||||||
@@ -20,8 +26,6 @@ function MailLayoutPreview({
|
|||||||
value: MailLayout;
|
value: MailLayout;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const isSplit = value === 'split';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -33,7 +37,7 @@ function MailLayoutPreview({
|
|||||||
<div className="flex h-28">
|
<div className="flex h-28">
|
||||||
<div className="w-11 border-r border-border bg-muted/40" />
|
<div className="w-11 border-r border-border bg-muted/40" />
|
||||||
|
|
||||||
{isSplit ? (
|
{value === 'split' && (
|
||||||
<>
|
<>
|
||||||
<div className="w-28 border-r border-border bg-background">
|
<div className="w-28 border-r border-border bg-background">
|
||||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||||
@@ -56,15 +60,36 @@ function MailLayoutPreview({
|
|||||||
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
<div className="flex-1 bg-background px-2 py-2">
|
|
||||||
<div className="space-y-1.5">
|
{value === 'focus' && (
|
||||||
|
<div className="flex-1 bg-background">
|
||||||
|
{MAIL_LAYOUT_PREVIEW_ROWS_FOCUS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.subject}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
|
||||||
|
row.selected && 'bg-primary/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="truncate text-foreground">
|
||||||
|
<span className="font-medium">{row.sender}</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value === 'horizontal' && (
|
||||||
|
<div className="flex-1 flex flex-col bg-background">
|
||||||
|
<div className="border-b border-border bg-background">
|
||||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.subject}
|
key={row.subject}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-2 py-1 text-[10px]',
|
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
|
||||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
row.selected && 'bg-primary/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="truncate text-foreground">
|
<div className="truncate text-foreground">
|
||||||
@@ -74,6 +99,11 @@ function MailLayoutPreview({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 bg-background px-3 py-2">
|
||||||
|
<div className="h-2 w-20 rounded bg-foreground/10" />
|
||||||
|
<div className="mt-1.5 h-1.5 w-full rounded bg-foreground/10" />
|
||||||
|
<div className="mt-1 h-1.5 w-5/6 rounded bg-foreground/10" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,6 +130,7 @@ export function LayoutSettings() {
|
|||||||
options={[
|
options={[
|
||||||
{ value: 'split', label: tEmail('mail_layout.split') },
|
{ value: 'split', label: tEmail('mail_layout.split') },
|
||||||
{ value: 'focus', label: tEmail('mail_layout.focus') },
|
{ value: 'focus', label: tEmail('mail_layout.focus') },
|
||||||
|
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<MailLayoutPreview value={mailLayout} t={tEmail} />
|
<MailLayoutPreview value={mailLayout} t={tEmail} />
|
||||||
|
|||||||
@@ -189,10 +189,17 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
|||||||
|
|
||||||
const getInitials = () => {
|
const getInitials = () => {
|
||||||
if (name) {
|
if (name) {
|
||||||
const parts = name.trim().split(/\s+/);
|
const parts = name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((p) => p.replace(/^[^\p{L}\p{N}]+/u, ""))
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
return name.slice(0, 2).toUpperCase();
|
return name.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
if (email) {
|
if (email) {
|
||||||
|
|||||||
+7
-1
@@ -11,8 +11,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Encrypted user settings (SETTINGS_DATA_DIR).
|
# Encrypted user settings (SETTINGS_DATA_DIR).
|
||||||
- bulwark-settings:/app/data/settings
|
- bulwark-settings:/app/data/settings
|
||||||
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
|
# Admin configuration: config.json, policy.json, admin.json (passwordHash),
|
||||||
|
# plugins, themes, branding uploads (ADMIN_CONFIG_DIR). Can be mounted
|
||||||
|
# read-only after running the setup wizard - append `:ro` to lock it.
|
||||||
- bulwark-admin:/app/data/admin
|
- bulwark-admin:/app/data/admin
|
||||||
|
# Admin runtime state: admin-state.json (login timestamps), audit.log,
|
||||||
|
# setup token (ADMIN_STATE_DIR). Always read-write.
|
||||||
|
- bulwark-admin-state:/app/data/admin-state
|
||||||
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
|
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
|
||||||
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
|
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
|
||||||
- bulwark-telemetry:/app/data/telemetry
|
- bulwark-telemetry:/app/data/telemetry
|
||||||
@@ -35,4 +40,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
bulwark-settings:
|
bulwark-settings:
|
||||||
bulwark-admin:
|
bulwark-admin:
|
||||||
|
bulwark-admin-state:
|
||||||
bulwark-telemetry:
|
bulwark-telemetry:
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export default [
|
|||||||
"*.config.mjs",
|
"*.config.mjs",
|
||||||
"e2e/**",
|
"e2e/**",
|
||||||
"local-data/**/*.mjs",
|
"local-data/**/*.mjs",
|
||||||
|
"benchmark/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
||||||
|
|
||||||
|
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
|
||||||
|
// drop to materialize a real file. Firefox and Safari ignore it, so we only
|
||||||
|
// enable drag-out where it actually works.
|
||||||
|
export function isDragOutSupported(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
const uaData = (navigator as { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
|
||||||
|
if (uaData?.brands?.length) {
|
||||||
|
return uaData.brands.some((b) => /Chromium|Google Chrome|Microsoft Edge|Brave|Opera/i.test(b.brand));
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
if (/Firefox|FxiOS/.test(ua)) return false;
|
||||||
|
if (/^((?!chrome|android).)*safari/i.test(ua)) return false;
|
||||||
|
return /Chrome|Chromium|Edg\//.test(ua);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentDragSource {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
getBlobUrl: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAttachmentDragResult {
|
||||||
|
draggable: boolean;
|
||||||
|
onPointerEnter: () => void;
|
||||||
|
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP_HANDLERS: UseAttachmentDragResult = {
|
||||||
|
draggable: false,
|
||||||
|
onPointerEnter: () => {},
|
||||||
|
onDragStart: () => {},
|
||||||
|
onDragEnd: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAttachmentDrag(
|
||||||
|
source: AttachmentDragSource,
|
||||||
|
enabled: boolean,
|
||||||
|
): UseAttachmentDragResult {
|
||||||
|
const urlRef = useRef<string | null>(null);
|
||||||
|
const ownedRef = useRef<boolean>(false);
|
||||||
|
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (urlRef.current && ownedRef.current) {
|
||||||
|
URL.revokeObjectURL(urlRef.current);
|
||||||
|
}
|
||||||
|
urlRef.current = null;
|
||||||
|
ownedRef.current = false;
|
||||||
|
inFlightRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prefetch = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (urlRef.current || inFlightRef.current) return;
|
||||||
|
inFlightRef.current = source
|
||||||
|
.getBlobUrl()
|
||||||
|
.then((url) => {
|
||||||
|
if (url && !urlRef.current) {
|
||||||
|
urlRef.current = url;
|
||||||
|
// Mark as owned so we revoke on unmount. Callers that hand back a
|
||||||
|
// shared URL (e.g. a cached thumbnail blob URL) can return the same
|
||||||
|
// string each time — we still revoke once on unmount.
|
||||||
|
ownedRef.current = true;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => {
|
||||||
|
inFlightRef.current = null;
|
||||||
|
});
|
||||||
|
}, [enabled, source]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: DragEvent<HTMLDivElement>) => {
|
||||||
|
const url = urlRef.current;
|
||||||
|
const name = source.name || "download";
|
||||||
|
const type = source.type || "application/octet-stream";
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
// Blob isn't materialized yet. Kick off the fetch so the next attempt
|
||||||
|
// works, but cancel this drag so the user doesn't get a silent failure
|
||||||
|
// where the OS receives no file.
|
||||||
|
prefetch();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
|
||||||
|
// drop and writes a real file at the destination.
|
||||||
|
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
|
||||||
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
|
},
|
||||||
|
[source.name, source.type, prefetch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
// Keep the blob URL around briefly — Chromium asynchronously fetches the
|
||||||
|
// blob: URL after dragend fires, so revoking immediately races the OS.
|
||||||
|
if (urlRef.current && ownedRef.current) {
|
||||||
|
const url = urlRef.current;
|
||||||
|
urlRef.current = null;
|
||||||
|
ownedRef.current = false;
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!enabled) return NOOP_HANDLERS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
draggable: true,
|
||||||
|
onPointerEnter: prefetch,
|
||||||
|
onDragStart: handleDragStart,
|
||||||
|
onDragEnd: handleDragEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
+31
-3
@@ -1,6 +1,9 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { configManager } from "./lib/admin/config-manager";
|
import { configManager } from "./lib/admin/config-manager";
|
||||||
import { initAdminPassword } from "./lib/admin/password";
|
import { initAdminPassword } from "./lib/admin/password";
|
||||||
|
import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
|
||||||
|
import { detectSetupState } from "./lib/setup/state";
|
||||||
|
import { ensureSetupToken } from "./lib/setup/token";
|
||||||
|
|
||||||
const pkg = JSON.parse(
|
const pkg = JSON.parse(
|
||||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||||
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
|
|||||||
const current: string = pkg.version ?? "0.0.0";
|
const current: string = pkg.version ?? "0.0.0";
|
||||||
console.info(`Bulwark Webmail v${current}`);
|
console.info(`Bulwark Webmail v${current}`);
|
||||||
|
|
||||||
// Initialize admin config and password bootstrap
|
// Initialize admin config and password bootstrap. Migration runs first so
|
||||||
configManager.load()
|
// existing v1 layouts are split before anything reads admin.json.
|
||||||
|
migrateLegacyAdminLayout()
|
||||||
|
.then(() => configManager.load())
|
||||||
.then(() => initAdminPassword())
|
.then(() => initAdminPassword())
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
console.info("Admin dashboard initialized");
|
console.info("Admin dashboard initialized");
|
||||||
|
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
|
||||||
|
// setupComplete in config.json), generate/refresh the setup token and
|
||||||
|
// print it to the logs so the operator can complete the web wizard
|
||||||
|
// without execing into the container.
|
||||||
|
if (detectSetupState() === "bootstrap") {
|
||||||
|
try {
|
||||||
|
const token = await ensureSetupToken();
|
||||||
|
const port = process.env.PORT || "3000";
|
||||||
|
console.info("");
|
||||||
|
console.info("==============================================================");
|
||||||
|
console.info(" SETUP REQUIRED");
|
||||||
|
console.info(` Token: ${token}`);
|
||||||
|
console.info(` Open: http://<host>:${port}/setup?token=${token}`);
|
||||||
|
console.info(" Token expires in 1 hour. Restart the container to reissue.");
|
||||||
|
console.info("==============================================================");
|
||||||
|
console.info("");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"Failed to issue setup token:",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
// Anonymous telemetry - on by default. Admins can disable via the
|
// Anonymous telemetry - on by default. Admins can disable via the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { findReplyIdentityId } from '../reply-identity';
|
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
|
||||||
import type { Identity } from '../jmap/types';
|
import type { Identity } from '../jmap/types';
|
||||||
|
|
||||||
const identities: Identity[] = [
|
const identities: Identity[] = [
|
||||||
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
|
|||||||
|
|
||||||
expect(selected).toBeNull();
|
expect(selected).toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveReplyFrom', () => {
|
||||||
|
it('returns the matching identity with no override when exact match', () => {
|
||||||
|
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
|
||||||
|
.toEqual({ identityId: 'secondary' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips +tag before matching identities', () => {
|
||||||
|
expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }))
|
||||||
|
.toEqual({ identityId: 'primary' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces catch-all override when recipient is on an identity domain but not an identity', () => {
|
||||||
|
const result = resolveReplyFrom(identities, {
|
||||||
|
to: [{ email: 'stripe@primary.com', name: 'Stripe' }],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
identityId: 'primary',
|
||||||
|
overrideEmail: 'stripe@primary.com',
|
||||||
|
overrideName: 'Stripe',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers identity match over catch-all override when both appear', () => {
|
||||||
|
const result = resolveReplyFrom(identities, {
|
||||||
|
to: [{ email: 'harry@primary.com' }, { email: 'stripe@primary.com' }],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ identityId: 'primary' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when recipients are on foreign domains', () => {
|
||||||
|
expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] }))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+40
-2
@@ -62,5 +62,43 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
|
|||||||
return `${baseKey}::${accountId}`;
|
return `${baseKey}::${accountId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maximum number of accounts allowed */
|
/**
|
||||||
export const MAX_ACCOUNTS = 5;
|
* Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
|
||||||
|
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
|
||||||
|
* cookies on average - within Firefox's per-domain limit of 150.
|
||||||
|
*/
|
||||||
|
export const MAX_ACCOUNT_SLOTS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX cap for browsers using HTTP/1.1. Each account holds one persistent
|
||||||
|
* SSE connection for JMAP push; HTTP/1.1 caps origins at 6 concurrent
|
||||||
|
* connections, so 5 accounts leave one connection free for normal traffic.
|
||||||
|
* On HTTP/2+ this cap doesn't apply because streams are multiplexed.
|
||||||
|
*/
|
||||||
|
export const MAX_ACCOUNTS_HTTP1 = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the page has observed any HTTP/2 or HTTP/3 traffic.
|
||||||
|
*
|
||||||
|
* We walk recent resource-timing entries and treat a single h2/h3 sighting
|
||||||
|
* as a positive signal. Cross-origin entries may report an empty
|
||||||
|
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
|
||||||
|
* under-detect and fall back to the conservative cap - that's safe.
|
||||||
|
*/
|
||||||
|
export function isHttp2Available(): boolean {
|
||||||
|
if (typeof performance === 'undefined') return false;
|
||||||
|
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
const proto = entries[i].nextHopProtocol;
|
||||||
|
if (proto === 'h2' || proto === 'h3') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective per-browser account cap. Lifts to {@link MAX_ACCOUNT_SLOTS}
|
||||||
|
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
|
||||||
|
*/
|
||||||
|
export function getMaxAccounts(): number {
|
||||||
|
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
|
||||||
|
}
|
||||||
|
|||||||
+7
-14
@@ -1,28 +1,23 @@
|
|||||||
import { appendFile, stat, rename, mkdir } from 'node:fs/promises';
|
import { appendFile, stat, rename, readFile } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { ensureStateDir, getStatePath } from './paths';
|
||||||
import type { AuditEntry } from './types';
|
import type { AuditEntry } from './types';
|
||||||
|
|
||||||
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
|
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
const MAX_ROTATIONS = 3;
|
const MAX_ROTATIONS = 3;
|
||||||
|
const AUDIT_LOG_FILE = 'audit.log';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAuditLogPath(): string {
|
function getAuditLogPath(): string {
|
||||||
return path.join(getAdminDir(), 'audit.log');
|
return getStatePath(AUDIT_LOG_FILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append an audit entry to the admin audit log.
|
* Append an audit entry to the admin audit log. Stored under the state dir
|
||||||
|
* so it remains writable when the config dir is mounted read-only.
|
||||||
*/
|
*/
|
||||||
export async function auditLog(action: string, detail: Record<string, unknown>, ip: string): Promise<void> {
|
export async function auditLog(action: string, detail: Record<string, unknown>, ip: string): Promise<void> {
|
||||||
const dir = getAdminDir();
|
await ensureStateDir();
|
||||||
if (!existsSync(dir)) {
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: AuditEntry = {
|
const entry: AuditEntry = {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
@@ -64,7 +59,6 @@ async function rotateIfNeeded(logPath: string): Promise<void> {
|
|||||||
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
|
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
|
||||||
const logPath = getAuditLogPath();
|
const logPath = getAuditLogPath();
|
||||||
try {
|
try {
|
||||||
const { readFile } = await import('node:fs/promises');
|
|
||||||
const content = await readFile(logPath, 'utf-8');
|
const content = await readFile(logPath, 'utf-8');
|
||||||
const lines = content.trim().split('\n').filter(Boolean);
|
const lines = content.trim().split('\n').filter(Boolean);
|
||||||
|
|
||||||
@@ -77,7 +71,6 @@ export async function readAuditLog(page: number = 1, limit: number = 50, actionF
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = entries.length;
|
const total = entries.length;
|
||||||
// Return newest first
|
|
||||||
entries.reverse();
|
entries.reverse();
|
||||||
const start = (page - 1) * limit;
|
const start = (page - 1) * limit;
|
||||||
return { entries: entries.slice(start, start + limit), total };
|
return { entries: entries.slice(start, start + limit), total };
|
||||||
|
|||||||
+36
-14
@@ -1,13 +1,8 @@
|
|||||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||||
|
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvValue(value: string, type: string): unknown {
|
function parseEnvValue(value: string, type: string): unknown {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -127,6 +122,7 @@ class ConfigManager {
|
|||||||
* Update admin config overrides. Writes to disk.
|
* Update admin config overrides. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
|
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
|
||||||
|
assertWritable('update admin config');
|
||||||
Object.assign(this.adminConfig, updates);
|
Object.assign(this.adminConfig, updates);
|
||||||
await this.writeJsonFile('config.json', this.adminConfig);
|
await this.writeJsonFile('config.json', this.adminConfig);
|
||||||
}
|
}
|
||||||
@@ -135,10 +131,29 @@ class ConfigManager {
|
|||||||
* Remove an admin override, reverting to env/default.
|
* Remove an admin override, reverting to env/default.
|
||||||
*/
|
*/
|
||||||
async removeAdminOverride(key: string): Promise<void> {
|
async removeAdminOverride(key: string): Promise<void> {
|
||||||
|
assertWritable('remove admin override');
|
||||||
delete this.adminConfig[key];
|
delete this.adminConfig[key];
|
||||||
await this.writeJsonFile('config.json', this.adminConfig);
|
await this.writeJsonFile('config.json', this.adminConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the setup wizard has completed. Used by middleware to gate the
|
||||||
|
* /setup routes and the rest of the app.
|
||||||
|
*/
|
||||||
|
isSetupComplete(): boolean {
|
||||||
|
return this.adminConfig.setupComplete === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark setup wizard as complete. Called by the wizard's finish endpoint
|
||||||
|
* after all other config has been written. Refuses in read-only mode.
|
||||||
|
*/
|
||||||
|
async markSetupComplete(): Promise<void> {
|
||||||
|
assertWritable('mark setup complete');
|
||||||
|
this.adminConfig.setupComplete = true;
|
||||||
|
await this.writeJsonFile('config.json', this.adminConfig);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current settings policy.
|
* Get the current settings policy.
|
||||||
*/
|
*/
|
||||||
@@ -150,6 +165,7 @@ class ConfigManager {
|
|||||||
* Update the settings policy. Writes to disk.
|
* Update the settings policy. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||||
|
assertWritable('update settings policy');
|
||||||
this.policyCache = {
|
this.policyCache = {
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
@@ -167,7 +183,7 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
||||||
const filePath = path.join(getAdminDir(), filename);
|
const filePath = getConfigPath(filename);
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(filePath, 'utf-8');
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
@@ -179,15 +195,21 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
||||||
const dir = getAdminDir();
|
await ensureConfigDir();
|
||||||
if (!existsSync(dir)) {
|
const targetPath = getConfigPath(filename);
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
const targetPath = path.join(dir, filename);
|
|
||||||
const tmpPath = targetPath + '.tmp';
|
const tmpPath = targetPath + '.tmp';
|
||||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
await rename(tmpPath, targetPath);
|
await rename(tmpPath, targetPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configManager = new ConfigManager();
|
// Stash the singleton on globalThis so HMR / multiple module-evaluation
|
||||||
|
// boundaries (middleware vs route handlers in dev with turbopack) all share
|
||||||
|
// the same in-memory state. Without this, marking setupComplete=true in a
|
||||||
|
// route handler is invisible to the next middleware run, and the wizard
|
||||||
|
// redirect after finish never fires.
|
||||||
|
const SINGLETON_KEY = Symbol.for('bulwark.admin.configManager');
|
||||||
|
type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager };
|
||||||
|
const g = globalThis as GlobalWithConfig;
|
||||||
|
export const configManager: ConfigManager =
|
||||||
|
g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager());
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
ensureConfigDir,
|
||||||
|
ensureStateDir,
|
||||||
|
getConfigPath,
|
||||||
|
getStatePath,
|
||||||
|
isConfigReadOnly,
|
||||||
|
} from './paths';
|
||||||
|
import type { AdminConfigData, AdminStateData } from './types';
|
||||||
|
|
||||||
|
const MIGRATION_MARKER = '.migrated-v2';
|
||||||
|
|
||||||
|
interface LegacyAdminData {
|
||||||
|
passwordHash: string;
|
||||||
|
createdAt?: string;
|
||||||
|
lastLogin?: string | null;
|
||||||
|
passwordChangedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot migration from the v1 layout (everything mixed in `data/admin/`)
|
||||||
|
* to the v2 layout (config + state split, see lib/admin/paths.ts).
|
||||||
|
*
|
||||||
|
* Idempotent: writes a `.migrated-v2` marker into the config dir on success.
|
||||||
|
*
|
||||||
|
* Migrations performed:
|
||||||
|
* 1. admin.json with timestamps → admin.json (passwordHash only) +
|
||||||
|
* admin-state.json (createdAt, lastLogin, passwordChangedAt)
|
||||||
|
* 2. audit.log moved from config dir to state dir (by rename if same FS,
|
||||||
|
* else copy + delete).
|
||||||
|
*
|
||||||
|
* Skipped silently when the config dir is read-only - operators who already
|
||||||
|
* locked their config volume must do the migration manually before mounting
|
||||||
|
* :ro.
|
||||||
|
*/
|
||||||
|
export async function migrateLegacyAdminLayout(): Promise<void> {
|
||||||
|
if (isConfigReadOnly()) return;
|
||||||
|
|
||||||
|
const markerPath = getConfigPath(MIGRATION_MARKER);
|
||||||
|
if (existsSync(markerPath)) return;
|
||||||
|
|
||||||
|
let didWork = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
didWork = (await migrateAdminJson()) || didWork;
|
||||||
|
didWork = (await migrateAuditLog()) || didWork;
|
||||||
|
|
||||||
|
await ensureConfigDir();
|
||||||
|
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
|
||||||
|
if (didWork) {
|
||||||
|
logger.info('Admin layout migrated to v2 (config/state split)');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Admin layout migration failed; will retry on next boot', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the existing admin.json carries timestamp fields (legacy mixed layout),
|
||||||
|
* split them into admin-state.json and rewrite admin.json without them.
|
||||||
|
* Returns true if a migration was performed.
|
||||||
|
*/
|
||||||
|
async function migrateAdminJson(): Promise<boolean> {
|
||||||
|
const adminJsonPath = getConfigPath('admin.json');
|
||||||
|
if (!existsSync(adminJsonPath)) return false;
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(adminJsonPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: LegacyAdminData;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw) as LegacyAdminData;
|
||||||
|
} catch {
|
||||||
|
logger.warn('admin.json is not valid JSON; skipping migration');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLegacyFields =
|
||||||
|
'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data;
|
||||||
|
if (!hasLegacyFields) return false; // already in v2 shape
|
||||||
|
|
||||||
|
if (!data.passwordHash || typeof data.passwordHash !== 'string') {
|
||||||
|
logger.warn('admin.json missing passwordHash; skipping migration');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const stateData: AdminStateData = {
|
||||||
|
createdAt: data.createdAt ?? now,
|
||||||
|
lastLogin: data.lastLogin ?? null,
|
||||||
|
passwordChangedAt: data.passwordChangedAt ?? now,
|
||||||
|
};
|
||||||
|
const configData: AdminConfigData = { passwordHash: data.passwordHash };
|
||||||
|
|
||||||
|
await ensureStateDir();
|
||||||
|
const statePath = getStatePath('admin-state.json');
|
||||||
|
|
||||||
|
// If admin-state.json already exists, prefer its values: a previous
|
||||||
|
// migration may have succeeded and recorded fresh login timestamps that
|
||||||
|
// we'd otherwise stomp. The legacy admin.json data is older by definition.
|
||||||
|
if (!existsSync(statePath)) {
|
||||||
|
const stateTmp = statePath + '.tmp';
|
||||||
|
await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8');
|
||||||
|
await rename(stateTmp, statePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configTmp = adminJsonPath + '.tmp';
|
||||||
|
await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8');
|
||||||
|
await rename(configTmp, adminJsonPath);
|
||||||
|
|
||||||
|
logger.info('Migrated admin.json: split timestamps into admin-state.json');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move audit.log from the config dir to the state dir if present. Returns
|
||||||
|
* true if a migration was performed. Also moves rotated copies (audit.log.1
|
||||||
|
* through .3).
|
||||||
|
*/
|
||||||
|
async function migrateAuditLog(): Promise<boolean> {
|
||||||
|
const sources = [
|
||||||
|
'audit.log',
|
||||||
|
'audit.log.1',
|
||||||
|
'audit.log.2',
|
||||||
|
'audit.log.3',
|
||||||
|
];
|
||||||
|
|
||||||
|
let moved = false;
|
||||||
|
for (const name of sources) {
|
||||||
|
const src = getConfigPath(name);
|
||||||
|
if (!existsSync(src)) continue;
|
||||||
|
|
||||||
|
await ensureStateDir();
|
||||||
|
const dst = getStatePath(name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Same-FS rename is atomic. Falls through to copy if cross-device.
|
||||||
|
await rename(src, dst);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'EXDEV') {
|
||||||
|
// Cross-device: copy bytes, then delete source.
|
||||||
|
const data = await readFile(src);
|
||||||
|
await writeFile(dst, data);
|
||||||
|
await unlink(src);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
logger.info('Migrated audit.log to state dir');
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns approximate size of legacy data still mixed in the config dir
|
||||||
|
* (for diagnostics / admin UI). Always returns 0 once migration has run.
|
||||||
|
*/
|
||||||
|
export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> {
|
||||||
|
let adminJsonHasTimestamps = false;
|
||||||
|
const adminJsonPath = getConfigPath('admin.json');
|
||||||
|
if (existsSync(adminJsonPath)) {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(adminJsonPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
adminJsonHasTimestamps =
|
||||||
|
'createdAt' in parsed ||
|
||||||
|
'lastLogin' in parsed ||
|
||||||
|
'passwordChangedAt' in parsed;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let auditLogInConfigDir = false;
|
||||||
|
try {
|
||||||
|
await stat(getConfigPath('audit.log'));
|
||||||
|
auditLogInConfigDir = true;
|
||||||
|
} catch {
|
||||||
|
/* not present - good */
|
||||||
|
}
|
||||||
|
|
||||||
|
return { adminJsonHasTimestamps, auditLogInConfigDir };
|
||||||
|
}
|
||||||
+115
-84
@@ -1,9 +1,14 @@
|
|||||||
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { AdminData } from './types';
|
import {
|
||||||
|
ensureConfigDir,
|
||||||
|
ensureStateDir,
|
||||||
|
getConfigPath,
|
||||||
|
getStatePath,
|
||||||
|
assertWritable,
|
||||||
|
} from './paths';
|
||||||
|
import type { AdminConfigData, AdminStateData } from './types';
|
||||||
|
|
||||||
const SCRYPT_KEYLEN = 64;
|
const SCRYPT_KEYLEN = 64;
|
||||||
const SCRYPT_COST = 16384; // 2^14
|
const SCRYPT_COST = 16384; // 2^14
|
||||||
@@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8;
|
|||||||
const SCRYPT_PARALLELIZATION = 1;
|
const SCRYPT_PARALLELIZATION = 1;
|
||||||
const SALT_LENGTH = 32;
|
const SALT_LENGTH = 32;
|
||||||
|
|
||||||
function getAdminDir(): string {
|
const ADMIN_CONFIG_FILE = 'admin.json';
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
const ADMIN_STATE_FILE = 'admin-state.json';
|
||||||
}
|
|
||||||
|
|
||||||
function getAdminJsonPath(): string {
|
|
||||||
return path.join(getAdminDir(), 'admin.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashPassword(password: string): Promise<string> {
|
function hashPassword(password: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise<string> {
|
|||||||
|
|
||||||
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Support both scrypt format and bcrypt-prefixed values
|
|
||||||
if (stored.startsWith('$scrypt$')) {
|
if (stored.startsWith('$scrypt$')) {
|
||||||
const parts = stored.split('$');
|
const parts = stored.split('$');
|
||||||
// $scrypt$N=...,r=...,p=...$salt$hash
|
|
||||||
if (parts.length !== 5) return resolve(false);
|
if (parts.length !== 5) return resolve(false);
|
||||||
const paramStr = parts[2];
|
const paramStr = parts[2];
|
||||||
const salt = Buffer.from(parts[3], 'base64');
|
const salt = Buffer.from(parts[3], 'base64');
|
||||||
@@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|||||||
resolve(timingSafeEqual(derivedKey, storedHash));
|
resolve(timingSafeEqual(derivedKey, storedHash));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unknown format
|
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -63,50 +60,84 @@ function isHashed(value: string): boolean {
|
|||||||
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
|
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAdminData(): Promise<AdminData | null> {
|
// ─── Disk I/O ───────────────────────────────────────────────────────────────
|
||||||
const filePath = getAdminJsonPath();
|
|
||||||
|
async function readJson<T>(filePath: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(filePath, 'utf-8');
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
return JSON.parse(raw) as AdminData;
|
return JSON.parse(raw) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.warn('Failed to read admin file', {
|
||||||
|
filePath,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeAdminData(data: AdminData): Promise<void> {
|
async function readConfigData(): Promise<AdminConfigData | null> {
|
||||||
const dir = getAdminDir();
|
return readJson<AdminConfigData>(getConfigPath(ADMIN_CONFIG_FILE));
|
||||||
if (!existsSync(dir)) {
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
const targetPath = getAdminJsonPath();
|
|
||||||
const tmpPath = targetPath + '.tmp';
|
|
||||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
||||||
await rename(tmpPath, targetPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedAdminData: AdminData | null = null;
|
async function readStateData(): Promise<AdminStateData | null> {
|
||||||
|
return readJson<AdminStateData>(getStatePath(ADMIN_STATE_FILE));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeConfigData(data: AdminConfigData): Promise<void> {
|
||||||
|
assertWritable('save admin password');
|
||||||
|
await ensureConfigDir();
|
||||||
|
const target = getConfigPath(ADMIN_CONFIG_FILE);
|
||||||
|
const tmp = target + '.tmp';
|
||||||
|
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
await rename(tmp, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStateData(data: AdminStateData): Promise<void> {
|
||||||
|
await ensureStateDir();
|
||||||
|
const target = getStatePath(ADMIN_STATE_FILE);
|
||||||
|
const tmp = target + '.tmp';
|
||||||
|
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
await rename(tmp, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cache & init ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cachedConfig: AdminConfigData | null = null;
|
||||||
|
let cachedState: AdminStateData | null = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
|
function freshState(): AdminStateData {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return { createdAt: now, lastLogin: null, passwordChangedAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize admin password on startup.
|
* Initialize admin password on startup.
|
||||||
* If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
|
* - If admin.json exists, use it (state file may or may not exist; created on first need).
|
||||||
* Returns true if admin is enabled.
|
* - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it.
|
||||||
|
* - Otherwise, admin dashboard stays disabled.
|
||||||
*/
|
*/
|
||||||
export async function initAdminPassword(): Promise<boolean> {
|
export async function initAdminPassword(): Promise<boolean> {
|
||||||
if (initialized) return cachedAdminData !== null;
|
if (initialized) return cachedConfig !== null;
|
||||||
|
|
||||||
// Check persistent file first
|
const existingConfig = await readConfigData();
|
||||||
const existing = await readAdminData();
|
if (existingConfig) {
|
||||||
if (existing) {
|
cachedConfig = existingConfig;
|
||||||
cachedAdminData = existing;
|
cachedState = (await readStateData()) ?? freshState();
|
||||||
|
if (!(await readStateData())) {
|
||||||
|
// No state file yet (fresh install or migration); create it.
|
||||||
|
try {
|
||||||
|
await writeStateData(cachedState);
|
||||||
|
} catch {
|
||||||
|
/* state dir may not be writable yet during early boot probes */
|
||||||
|
}
|
||||||
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
logger.info('Admin dashboard enabled (password loaded from admin.json)');
|
logger.info('Admin dashboard enabled (password loaded from admin.json)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check env var
|
|
||||||
const envPassword = process.env.ADMIN_PASSWORD;
|
const envPassword = process.env.ADMIN_PASSWORD;
|
||||||
if (!envPassword) {
|
if (!envPassword) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
@@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHashed(envPassword)) {
|
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
||||||
// Already hashed in env - save to file
|
cachedConfig = { passwordHash: hash };
|
||||||
const data: AdminData = {
|
cachedState = freshState();
|
||||||
passwordHash: envPassword,
|
await writeConfigData(cachedConfig);
|
||||||
createdAt: new Date().toISOString(),
|
await writeStateData(cachedState);
|
||||||
lastLogin: null,
|
|
||||||
passwordChangedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await writeAdminData(data);
|
|
||||||
cachedAdminData = data;
|
|
||||||
initialized = true;
|
|
||||||
logger.info('Admin password hash saved to admin.json from environment variable');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleartext - hash it
|
|
||||||
const hash = await hashPassword(envPassword);
|
|
||||||
const data: AdminData = {
|
|
||||||
passwordHash: hash,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
lastLogin: null,
|
|
||||||
passwordChangedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await writeAdminData(data);
|
|
||||||
cachedAdminData = data;
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
|
if (isHashed(envPassword)) {
|
||||||
|
logger.info('Admin password hash saved to admin.json from environment variable');
|
||||||
|
} else {
|
||||||
|
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
* Verify a password against the stored admin hash.
|
* Verify a password against the stored admin hash.
|
||||||
*/
|
*/
|
||||||
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
||||||
if (!cachedAdminData) {
|
if (!cachedConfig) cachedConfig = await readConfigData();
|
||||||
cachedAdminData = await readAdminData();
|
if (!cachedConfig) return false;
|
||||||
}
|
return verifyPassword(password, cachedConfig.passwordHash);
|
||||||
if (!cachedAdminData) return false;
|
|
||||||
return verifyPassword(password, cachedAdminData.passwordHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
|||||||
if (!valid) return false;
|
if (!valid) return false;
|
||||||
|
|
||||||
const hash = await hashPassword(newPassword);
|
const hash = await hashPassword(newPassword);
|
||||||
if (!cachedAdminData) return false;
|
cachedConfig = { passwordHash: hash };
|
||||||
|
await writeConfigData(cachedConfig);
|
||||||
|
|
||||||
cachedAdminData = {
|
cachedState = {
|
||||||
...cachedAdminData,
|
...(cachedState ?? freshState()),
|
||||||
passwordHash: hash,
|
|
||||||
passwordChangedAt: new Date().toISOString(),
|
passwordChangedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await writeAdminData(cachedAdminData);
|
await writeStateData(cachedState);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the admin password without verifying a current one. Used by the setup
|
||||||
|
* wizard during initial bootstrap. Refuses to overwrite an existing password.
|
||||||
|
*/
|
||||||
|
export async function setInitialAdminPassword(newPassword: string): Promise<boolean> {
|
||||||
|
const existing = await readConfigData();
|
||||||
|
if (existing) return false;
|
||||||
|
const hash = await hashPassword(newPassword);
|
||||||
|
cachedConfig = { passwordHash: hash };
|
||||||
|
cachedState = freshState();
|
||||||
|
await writeConfigData(cachedConfig);
|
||||||
|
await writeStateData(cachedState);
|
||||||
|
initialized = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
|||||||
* Update the last login timestamp.
|
* Update the last login timestamp.
|
||||||
*/
|
*/
|
||||||
export async function updateLastLogin(): Promise<void> {
|
export async function updateLastLogin(): Promise<void> {
|
||||||
if (!cachedAdminData) return;
|
if (!cachedConfig) return;
|
||||||
cachedAdminData = {
|
cachedState = {
|
||||||
...cachedAdminData,
|
...(cachedState ?? freshState()),
|
||||||
lastLogin: new Date().toISOString(),
|
lastLogin: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await writeAdminData(cachedAdminData);
|
try {
|
||||||
|
await writeStateData(cachedState);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to update admin last-login state', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if admin dashboard is enabled (has a password configured).
|
* Check if admin dashboard is enabled (has a password configured).
|
||||||
*/
|
*/
|
||||||
export function isAdminEnabled(): boolean {
|
export function isAdminEnabled(): boolean {
|
||||||
return cachedAdminData !== null;
|
return cachedConfig !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get admin metadata (without the hash).
|
* Get admin metadata (without the hash).
|
||||||
*/
|
*/
|
||||||
export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
|
export function getAdminMeta(): AdminStateData | null {
|
||||||
if (!cachedAdminData) return null;
|
if (!cachedConfig) return null;
|
||||||
return {
|
return cachedState ?? freshState();
|
||||||
createdAt: cachedAdminData.createdAt,
|
|
||||||
lastLogin: cachedAdminData.lastLogin,
|
|
||||||
passwordChangedAt: cachedAdminData.passwordChangedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin data directories.
|
||||||
|
*
|
||||||
|
* Two dirs intentionally split (issue #226):
|
||||||
|
* - CONFIG: holds operator-authored state (config.json, policy.json,
|
||||||
|
* admin.json passwordHash, plugins, themes, branding uploads). Can be
|
||||||
|
* mounted read-only after initial setup.
|
||||||
|
* - STATE: holds runtime mutations (admin-state.json with login timestamps,
|
||||||
|
* audit.log, .setup-token). Always read-write.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* getConfigDir()
|
||||||
|
* 1. ADMIN_CONFIG_DIR
|
||||||
|
* 2. ADMIN_DATA_DIR (legacy)
|
||||||
|
* 3. <cwd>/data/admin
|
||||||
|
*
|
||||||
|
* getStateDir()
|
||||||
|
* 1. ADMIN_STATE_DIR
|
||||||
|
* 2. <ADMIN_CONFIG_DIR>/state - if config dir was set explicitly
|
||||||
|
* 3. <ADMIN_DATA_DIR>/state - back-compat: stays on the legacy volume
|
||||||
|
* 4. <cwd>/data/admin-state - fresh-install default; matches the
|
||||||
|
* sibling mount in docker-compose.yml
|
||||||
|
*
|
||||||
|
* The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working
|
||||||
|
* unchanged: everything ends up under it, with state in a `state/` subdir.
|
||||||
|
* Fresh installs and the docker-compose default keep state in a separate
|
||||||
|
* sibling dir so the config dir can be mounted :ro after setup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getConfigDir(): string {
|
||||||
|
return (
|
||||||
|
process.env.ADMIN_CONFIG_DIR ||
|
||||||
|
process.env.ADMIN_DATA_DIR ||
|
||||||
|
path.join(process.cwd(), 'data', 'admin')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStateDir(): string {
|
||||||
|
if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR;
|
||||||
|
if (process.env.ADMIN_CONFIG_DIR) {
|
||||||
|
return path.join(process.env.ADMIN_CONFIG_DIR, 'state');
|
||||||
|
}
|
||||||
|
if (process.env.ADMIN_DATA_DIR) {
|
||||||
|
return path.join(process.env.ADMIN_DATA_DIR, 'state');
|
||||||
|
}
|
||||||
|
return path.join(process.cwd(), 'data', 'admin-state');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigPath(filename: string): string {
|
||||||
|
return path.join(getConfigDir(), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatePath(filename: string): string {
|
||||||
|
return path.join(getStateDir(), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureConfigDir(): Promise<void> {
|
||||||
|
const dir = getConfigDir();
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureStateDir(): Promise<void> {
|
||||||
|
const dir = getStateDir();
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Read-only mode ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cachedReadOnly: boolean | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true
|
||||||
|
* after running the setup wizard and remounting the volume :ro.
|
||||||
|
*
|
||||||
|
* When true, all writes to the config dir are refused at the application
|
||||||
|
* layer (cleaner error than a mid-request EROFS).
|
||||||
|
*/
|
||||||
|
export function isConfigReadOnly(): boolean {
|
||||||
|
if (cachedReadOnly !== null) return cachedReadOnly;
|
||||||
|
const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase();
|
||||||
|
cachedReadOnly = v === 'true' || v === '1' || v === 'yes';
|
||||||
|
return cachedReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the config dir by writing a temp file. Used to auto-detect RO mounts
|
||||||
|
* when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup;
|
||||||
|
* cheap on local FS, can be slow on networked FS, hence opt-in.
|
||||||
|
*/
|
||||||
|
export async function probeConfigReadOnly(): Promise<boolean> {
|
||||||
|
if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly();
|
||||||
|
try {
|
||||||
|
const probe = path.join(getConfigDir(), '.rw-probe');
|
||||||
|
await writeFile(probe, '');
|
||||||
|
await unlink(probe);
|
||||||
|
cachedReadOnly = false;
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
cachedReadOnly = true;
|
||||||
|
logger.info('Config dir is read-only (auto-detected)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfigReadOnlyError extends Error {
|
||||||
|
constructor(operation: string) {
|
||||||
|
super(
|
||||||
|
`Cannot ${operation}: configuration is read-only. ` +
|
||||||
|
`Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.`
|
||||||
|
);
|
||||||
|
this.name = 'ConfigReadOnlyError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertWritable(operation: string): void {
|
||||||
|
if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation);
|
||||||
|
}
|
||||||
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getConfigDir, assertWritable } from './paths';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginConfigDir(): string {
|
function getPluginConfigDir(): string {
|
||||||
return path.join(getAdminDir(), 'plugin-config');
|
return path.join(getConfigDir(), 'plugin-config');
|
||||||
}
|
}
|
||||||
|
|
||||||
function configPath(pluginId: string): string {
|
function configPath(pluginId: string): string {
|
||||||
@@ -41,6 +38,7 @@ export async function getPluginConfig(pluginId: string): Promise<Record<string,
|
|||||||
* Set a single config key for a plugin.
|
* Set a single config key for a plugin.
|
||||||
*/
|
*/
|
||||||
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
|
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
assertWritable('update plugin config');
|
||||||
const dir = getPluginConfigDir();
|
const dir = getPluginConfigDir();
|
||||||
await ensureDir(dir);
|
await ensureDir(dir);
|
||||||
|
|
||||||
@@ -57,6 +55,7 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn
|
|||||||
* Delete a single config key for a plugin.
|
* Delete a single config key for a plugin.
|
||||||
*/
|
*/
|
||||||
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
|
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
|
||||||
|
assertWritable('delete plugin config key');
|
||||||
const config = await getPluginConfig(pluginId);
|
const config = await getPluginConfig(pluginId);
|
||||||
delete config[key];
|
delete config[key];
|
||||||
|
|
||||||
@@ -77,5 +76,6 @@ export async function deletePluginConfigKey(pluginId: string, key: string): Prom
|
|||||||
* Delete all config for a plugin (used when uninstalling).
|
* Delete all config for a plugin (used when uninstalling).
|
||||||
*/
|
*/
|
||||||
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
|
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
|
||||||
|
assertWritable('delete plugin config');
|
||||||
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import { existsSync } from 'node:fs';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getConfigDir, assertWritable } from './paths';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginsDir(): string {
|
function getPluginsDir(): string {
|
||||||
return path.join(getAdminDir(), 'plugins');
|
return path.join(getConfigDir(), 'plugins');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThemesDir(): string {
|
function getThemesDir(): string {
|
||||||
return path.join(getAdminDir(), 'themes');
|
return path.join(getConfigDir(), 'themes');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
@@ -141,6 +138,7 @@ export async function savePlugin(
|
|||||||
plugin: ServerPlugin,
|
plugin: ServerPlugin,
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertWritable('install plugin');
|
||||||
const dir = getPluginsDir();
|
const dir = getPluginsDir();
|
||||||
await ensureDir(dir);
|
await ensureDir(dir);
|
||||||
|
|
||||||
@@ -171,6 +169,7 @@ export async function savePlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
|
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
|
||||||
|
assertWritable('update plugin metadata');
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === id);
|
const idx = registry.plugins.findIndex(p => p.id === id);
|
||||||
if (idx < 0) return null;
|
if (idx < 0) return null;
|
||||||
@@ -181,6 +180,7 @@ export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerP
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePlugin(id: string): Promise<boolean> {
|
export async function deletePlugin(id: string): Promise<boolean> {
|
||||||
|
assertWritable('delete plugin');
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === id);
|
const idx = registry.plugins.findIndex(p => p.id === id);
|
||||||
if (idx < 0) return false;
|
if (idx < 0) return false;
|
||||||
@@ -221,6 +221,7 @@ export async function saveTheme(
|
|||||||
theme: ServerTheme,
|
theme: ServerTheme,
|
||||||
css: string,
|
css: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertWritable('install theme');
|
||||||
const dir = getThemesDir();
|
const dir = getThemesDir();
|
||||||
await ensureDir(dir);
|
await ensureDir(dir);
|
||||||
|
|
||||||
@@ -240,6 +241,7 @@ export async function saveTheme(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
|
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
|
||||||
|
assertWritable('update theme metadata');
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
const idx = registry.themes.findIndex(t => t.id === id);
|
const idx = registry.themes.findIndex(t => t.id === id);
|
||||||
if (idx < 0) return null;
|
if (idx < 0) return null;
|
||||||
@@ -250,6 +252,7 @@ export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTh
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTheme(id: string): Promise<boolean> {
|
export async function deleteTheme(id: string): Promise<boolean> {
|
||||||
|
assertWritable('delete theme');
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
const idx = registry.themes.findIndex(t => t.id === id);
|
const idx = registry.themes.findIndex(t => t.id === id);
|
||||||
if (idx < 0) return false;
|
if (idx < 0) return false;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { getSessionSecret } from '@/lib/auth/session-secret';
|
||||||
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
|
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
|
||||||
import type { AdminSessionPayload } from './types';
|
import type { AdminSessionPayload } from './types';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ const TAG_LENGTH = 16;
|
|||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
const secret = getSessionSecret();
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
if (secret.length < MIN_SECRET_LENGTH) {
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
+19
-1
@@ -1,12 +1,30 @@
|
|||||||
// Admin dashboard types
|
// Admin dashboard types
|
||||||
|
|
||||||
export interface AdminData {
|
/**
|
||||||
|
* Operator-authored admin record. Lives in admin.json under the config dir
|
||||||
|
* and can be mounted read-only after setup. Only the password hash itself
|
||||||
|
* is config; mutable timestamps live in AdminStateData.
|
||||||
|
*/
|
||||||
|
export interface AdminConfigData {
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-mutable admin record. Lives in admin-state.json under the state
|
||||||
|
* dir. Updated on every login and password change, so it must stay writable.
|
||||||
|
*/
|
||||||
|
export interface AdminStateData {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastLogin: string | null;
|
lastLogin: string | null;
|
||||||
passwordChangedAt: string;
|
passwordChangedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined view used by getAdminMeta() and tests. Constructed by merging
|
||||||
|
* admin.json + admin-state.json at read time.
|
||||||
|
*/
|
||||||
|
export interface AdminData extends AdminConfigData, AdminStateData {}
|
||||||
|
|
||||||
export interface AdminSessionPayload {
|
export interface AdminSessionPayload {
|
||||||
role: 'admin';
|
role: 'admin';
|
||||||
iat: number;
|
iat: number;
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { getSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
@@ -9,7 +9,7 @@ const TAG_LENGTH = 16;
|
|||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
const secret = getSessionSecret();
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
if (secret.length < MIN_SECRET_LENGTH) {
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const SESSION_COOKIE = 'jmap_session';
|
export const SESSION_COOKIE = 'jmap_session';
|
||||||
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
/** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
|
||||||
export function sessionCookieName(slot: number): string {
|
export function sessionCookieName(slot: number): string {
|
||||||
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
|
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the session secret from any of the supported sources, in priority
|
||||||
|
* order:
|
||||||
|
* 1. SESSION_SECRET env var
|
||||||
|
* 2. SESSION_SECRET_FILE-pointed file
|
||||||
|
* 3. Admin override in config.json (set by the setup wizard)
|
||||||
|
*
|
||||||
|
* Returns an empty string when nothing is configured. Callers must treat
|
||||||
|
* empty as "feature disabled" rather than crashing.
|
||||||
|
*
|
||||||
|
* The configManager fallback exists so the web installer can persist the
|
||||||
|
* secret without touching .env files. It only takes effect if the env vars
|
||||||
|
* aren't set, so existing deployments aren't affected.
|
||||||
|
*/
|
||||||
|
export function getSessionSecret(): string {
|
||||||
|
const fromEnv = process.env.SESSION_SECRET;
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
const fromFile = readFileEnv(process.env.SESSION_SECRET_FILE);
|
||||||
|
if (fromFile) return fromFile;
|
||||||
|
|
||||||
|
const fromAdmin = configManager.get<string>('sessionSecret', '');
|
||||||
|
return fromAdmin || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSessionSecret(): boolean {
|
||||||
|
return getSessionSecret().length > 0;
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ export interface IJMAPClient {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
inReplyTo?: string[],
|
inReplyTo?: string[],
|
||||||
references?: string[],
|
references?: string[],
|
||||||
|
envelopeMailFrom?: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
sendImipReply(opts: {
|
sendImipReply(opts: {
|
||||||
|
|||||||
+59
-8
@@ -95,6 +95,41 @@ const EMAIL_LIST_PROPERTIES = [
|
|||||||
"hasAttachment",
|
"hasAttachment",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
||||||
|
// includeInAvailability, and the default-alerts properties. Without an explicit
|
||||||
|
// `properties` list the share indicator and share dialog can't see existing
|
||||||
|
// shares after a fresh login (only the optimistic in-memory update from the
|
||||||
|
// share action would carry it). Always request the full set we render.
|
||||||
|
const CALENDAR_PROPERTIES = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"color",
|
||||||
|
"sortOrder",
|
||||||
|
"isSubscribed",
|
||||||
|
"isVisible",
|
||||||
|
"isDefault",
|
||||||
|
"includeInAvailability",
|
||||||
|
"defaultAlertsWithTime",
|
||||||
|
"defaultAlertsWithoutTime",
|
||||||
|
"timeZone",
|
||||||
|
"shareWith",
|
||||||
|
"myRights",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Stalwart's default property list for AddressBook/get omits shareWith, so
|
||||||
|
// existing shares would be invisible after a fresh login.
|
||||||
|
const ADDRESS_BOOK_PROPERTIES = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"sortOrder",
|
||||||
|
"isDefault",
|
||||||
|
"isSubscribed",
|
||||||
|
"shareWith",
|
||||||
|
"myRights",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether a calendar object returned by the server is actually a
|
* Detect whether a calendar object returned by the server is actually a
|
||||||
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
|
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
|
||||||
@@ -2054,7 +2089,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
htmlBody?: string,
|
htmlBody?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
inReplyTo?: string[],
|
inReplyTo?: string[],
|
||||||
references?: string[]
|
references?: string[],
|
||||||
|
envelopeMailFrom?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const emailId = `send-${Date.now()}`;
|
const emailId = `send-${Date.now()}`;
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
@@ -2150,6 +2186,21 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When an explicit envelope MAIL FROM is provided (header From ≠ envelope,
|
||||||
|
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
||||||
|
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
||||||
|
// is omitted the server derives mailFrom from the Identity.
|
||||||
|
const submissionCreate = (submissionId: string): Record<string, unknown> => {
|
||||||
|
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
||||||
|
if (envelopeMailFrom) {
|
||||||
|
create.envelope = {
|
||||||
|
mailFrom: { email: envelopeMailFrom },
|
||||||
|
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { [submissionId]: create };
|
||||||
|
};
|
||||||
|
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
// Destroy the old draft and create a new email with the final body
|
// Destroy the old draft and create a new email with the final body
|
||||||
methodCalls.push(["Email/set", {
|
methodCalls.push(["Email/set", {
|
||||||
@@ -2162,7 +2213,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, "1"]);
|
}, "1"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
create: submissionCreate("1"),
|
||||||
onSuccessUpdateEmail,
|
onSuccessUpdateEmail,
|
||||||
}, "2"]);
|
}, "2"]);
|
||||||
} else {
|
} else {
|
||||||
@@ -2172,7 +2223,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, "0"]);
|
}, "0"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
create: submissionCreate("1"),
|
||||||
onSuccessUpdateEmail,
|
onSuccessUpdateEmail,
|
||||||
}, "1"]);
|
}, "1"]);
|
||||||
}
|
}
|
||||||
@@ -3143,7 +3194,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = this.getContactsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["AddressBook/get", { accountId }, "0"]
|
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||||||
@@ -3168,7 +3219,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["AddressBook/get", { accountId }, "0"]
|
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||||||
@@ -3612,7 +3663,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
try {
|
try {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = this.getCalendarsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/get", { accountId }, "0"]
|
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||||
@@ -3637,7 +3688,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/get", { accountId }, "0"]
|
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||||
@@ -3689,7 +3740,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// Fetch from the target account to find the created calendar
|
// Fetch from the target account to find the created calendar
|
||||||
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
|
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const fetchResponse = await this.request([
|
const fetchResponse = await this.request([
|
||||||
["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"]
|
["Calendar/get", { accountId: fetchAccountId, ids: [createdId], properties: CALENDAR_PROPERTIES }, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
|
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||||
const list = fetchResponse.methodResponses[0][1].list || [];
|
const list = fetchResponse.methodResponses[0][1].list || [];
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAU
|
|||||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||||
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
|
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
|
||||||
|
|
||||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
/** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
|
||||||
export function refreshTokenCookieName(slot: number): string {
|
export function refreshTokenCookieName(slot: number): string {
|
||||||
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Identity } from '@/lib/jmap/types';
|
|||||||
|
|
||||||
interface ReplyRecipient {
|
interface ReplyRecipient {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReplyRecipients {
|
interface ReplyRecipients {
|
||||||
@@ -29,6 +30,11 @@ function normalizeBaseEmailAddress(email: string): string {
|
|||||||
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
|
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function domainOf(email: string): string {
|
||||||
|
const at = email.indexOf('@');
|
||||||
|
return at > 0 ? email.slice(at + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
export function findReplyIdentityId(
|
export function findReplyIdentityId(
|
||||||
identities: Identity[],
|
identities: Identity[],
|
||||||
recipients?: ReplyRecipients,
|
recipients?: ReplyRecipients,
|
||||||
@@ -59,4 +65,93 @@ export function findReplyIdentityId(
|
|||||||
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
|
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
|
||||||
|
|
||||||
return baseIdentity?.id ?? null;
|
return baseIdentity?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyFromResolution {
|
||||||
|
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
|
||||||
|
identityId: string;
|
||||||
|
/**
|
||||||
|
* Override for the outgoing `From:` header. Populated when the incoming
|
||||||
|
* message was delivered to an address on a domain the user owns (by
|
||||||
|
* identity) but that isn't itself a configured identity — typical
|
||||||
|
* domain-catch-all deployments. When set, the composer should put this
|
||||||
|
* address (and `overrideName`) in the message's From header while sending
|
||||||
|
* through the chosen identity.
|
||||||
|
*/
|
||||||
|
overrideEmail?: string;
|
||||||
|
overrideName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the identity + optional header-From override for replying to a message.
|
||||||
|
*
|
||||||
|
* Decision order:
|
||||||
|
* 1. If a recipient address exactly matches an identity, reply as that
|
||||||
|
* identity with no override.
|
||||||
|
* 2. Else if a recipient matches an identity after stripping `+tag`
|
||||||
|
* sub-addressing, reply as that identity with no override.
|
||||||
|
* 3. Else if a recipient address is on a domain that one of the identities
|
||||||
|
* uses, treat that recipient as a catch-all alias: return the matching
|
||||||
|
* identity + the recipient as a header-From override.
|
||||||
|
* 4. Else return `null` (caller falls back to primary identity).
|
||||||
|
*/
|
||||||
|
export function resolveReplyFrom(
|
||||||
|
identities: Identity[],
|
||||||
|
recipients?: ReplyRecipients,
|
||||||
|
): ReplyFromResolution | null {
|
||||||
|
if (identities.length === 0 || !recipients) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const received: { email: string; name: string | undefined }[] = [
|
||||||
|
...(recipients.to || []),
|
||||||
|
...(recipients.cc || []),
|
||||||
|
...(recipients.bcc || []),
|
||||||
|
].flatMap((r) => {
|
||||||
|
const email = r.email?.trim();
|
||||||
|
if (!email) return [];
|
||||||
|
return [{ email, name: r.name?.trim() || undefined }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (received.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityEmails = new Set(identities.map((i) => normalizeEmailAddress(i.email)));
|
||||||
|
const identityBaseEmails = new Set(identities.map((i) => normalizeBaseEmailAddress(i.email)));
|
||||||
|
|
||||||
|
const exactIdentity = identities.find((i) =>
|
||||||
|
received.some((r) => normalizeEmailAddress(r.email) === normalizeEmailAddress(i.email)),
|
||||||
|
);
|
||||||
|
if (exactIdentity) {
|
||||||
|
return { identityId: exactIdentity.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseIdentity = identities.find((i) =>
|
||||||
|
received.some((r) => normalizeBaseEmailAddress(r.email) === normalizeBaseEmailAddress(i.email)),
|
||||||
|
);
|
||||||
|
if (baseIdentity) {
|
||||||
|
return { identityId: baseIdentity.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean));
|
||||||
|
|
||||||
|
const catchAll = received.find((r) => {
|
||||||
|
const email = normalizeEmailAddress(r.email);
|
||||||
|
if (identityEmails.has(email) || identityBaseEmails.has(normalizeBaseEmailAddress(email))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ownedDomains.has(domainOf(email));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (catchAll) {
|
||||||
|
const anchor = identities.find((i) => domainOf(i.email) === domainOf(catchAll.email)) || identities[0];
|
||||||
|
return {
|
||||||
|
identityId: anchor.id,
|
||||||
|
overrideEmail: catchAll.email,
|
||||||
|
overrideName: catchAll.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
@@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { getSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
|
const secret = getSessionSecret();
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { verifySetupToken } from './token';
|
||||||
|
|
||||||
|
export const SETUP_COOKIE = 'bulwark_setup_token';
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wizard "session" is just the setup token itself, set as an HttpOnly
|
||||||
|
* cookie after the operator pastes it into step 1. Subsequent step calls
|
||||||
|
* re-verify the cookie value against the .setup-token file. When the wizard
|
||||||
|
* finishes, the token file is deleted and any cookies become useless.
|
||||||
|
*
|
||||||
|
* No JWT, no separate signing key, no rotating session id. The lifecycle of
|
||||||
|
* the wizard maps 1:1 to the lifecycle of the token file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function authenticateWizardRequest(): Promise<boolean> {
|
||||||
|
const jar = await cookies();
|
||||||
|
const token = jar.get(SETUP_COOKIE)?.value;
|
||||||
|
if (!token) return false;
|
||||||
|
return verifySetupToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionCookieAttributes() {
|
||||||
|
return {
|
||||||
|
name: SETUP_COOKIE,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigPath, isConfigReadOnly } from '@/lib/admin/paths';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three lifecycle states for the running container.
|
||||||
|
*
|
||||||
|
* bootstrap - no config persisted yet and no JMAP_SERVER_URL env. The
|
||||||
|
* setup wizard is served at /setup; everything else 302s
|
||||||
|
* there.
|
||||||
|
* configured - setup wizard finished (admin override config.json carries
|
||||||
|
* setupComplete=true). Normal app; /setup returns 404.
|
||||||
|
* env-managed - JMAP_SERVER_URL is set in the environment, so the
|
||||||
|
* operator is configuring via .env (legacy / CI path). The
|
||||||
|
* wizard stays disabled.
|
||||||
|
*/
|
||||||
|
export type SetupState = 'bootstrap' | 'configured' | 'env-managed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap to call on every request. configManager keeps `setupComplete` in
|
||||||
|
* memory after the initial load, so this is just env reads + an in-memory
|
||||||
|
* boolean check.
|
||||||
|
*/
|
||||||
|
export function detectSetupState(): SetupState {
|
||||||
|
if (configManager.isSetupComplete()) return 'configured';
|
||||||
|
if (process.env.JMAP_SERVER_URL && process.env.JMAP_SERVER_URL.trim() !== '') {
|
||||||
|
return 'env-managed';
|
||||||
|
}
|
||||||
|
// Read-only config dir + no setupComplete flag means the volume was
|
||||||
|
// mounted :ro before the wizard ran. Fall through to bootstrap so the
|
||||||
|
// failure (write attempt during wizard) surfaces with a clear error
|
||||||
|
// rather than silently 404'ing /setup.
|
||||||
|
if (isConfigReadOnly()) return 'bootstrap';
|
||||||
|
return 'bootstrap';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the wizard's UI and APIs should be reachable.
|
||||||
|
*/
|
||||||
|
export function isSetupActive(): boolean {
|
||||||
|
return detectSetupState() === 'bootstrap';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persisted `.config-locked` marker the wizard drops when the operator
|
||||||
|
* checks "lock configuration after setup" on the review screen. Purely
|
||||||
|
* advisory - the actual locking is the operator's `:ro` mount or the
|
||||||
|
* ADMIN_CONFIG_READONLY env var. This file is what the admin UI uses to
|
||||||
|
* remind the operator that they intended to lock.
|
||||||
|
*/
|
||||||
|
export function lockMarkerExists(): boolean {
|
||||||
|
return existsSync(getConfigPath('.config-locked'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||||
|
|
||||||
|
const TOKEN_FILE = '.setup-token';
|
||||||
|
const TOKEN_BYTES = 32;
|
||||||
|
const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour
|
||||||
|
|
||||||
|
interface TokenPayload {
|
||||||
|
token: string;
|
||||||
|
issuedAt: number;
|
||||||
|
ttlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current token if one exists and hasn't expired. Stale tokens
|
||||||
|
* are deleted lazily - first stale read removes the file.
|
||||||
|
*/
|
||||||
|
async function readToken(): Promise<TokenPayload | null> {
|
||||||
|
const path = getStatePath(TOKEN_FILE);
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
try {
|
||||||
|
const raw = await readFile(path, 'utf-8');
|
||||||
|
const payload = JSON.parse(raw) as TokenPayload;
|
||||||
|
if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) {
|
||||||
|
try { await unlink(path); } catch { /* ok */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to read setup token', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate (or refresh) the setup token. Called at startup when the app
|
||||||
|
* detects bootstrap state. Idempotent: returns the existing token if it's
|
||||||
|
* still valid, otherwise issues a fresh one.
|
||||||
|
*
|
||||||
|
* The token lands in a file in ADMIN_STATE_DIR (always writable, never
|
||||||
|
* read-only) and is also printed to the container logs so the operator
|
||||||
|
* can copy it without execing into the container.
|
||||||
|
*/
|
||||||
|
export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise<string> {
|
||||||
|
const existing = await readToken();
|
||||||
|
if (existing) return existing.token;
|
||||||
|
|
||||||
|
await ensureStateDir();
|
||||||
|
const token = randomBytes(TOKEN_BYTES).toString('hex');
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
token,
|
||||||
|
issuedAt: Math.floor(Date.now() / 1000),
|
||||||
|
ttlSeconds,
|
||||||
|
};
|
||||||
|
const path = getStatePath(TOKEN_FILE);
|
||||||
|
await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a token submitted by the wizard. Constant-time comparison; never
|
||||||
|
* leak the stored token via timing.
|
||||||
|
*/
|
||||||
|
export async function verifySetupToken(submitted: string): Promise<boolean> {
|
||||||
|
if (!submitted || typeof submitted !== 'string') return false;
|
||||||
|
const stored = await readToken();
|
||||||
|
if (!stored) return false;
|
||||||
|
|
||||||
|
const a = Buffer.from(submitted);
|
||||||
|
const b = Buffer.from(stored.token);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the token file. Called by the wizard's finish endpoint after
|
||||||
|
* setupComplete=true is persisted.
|
||||||
|
*/
|
||||||
|
export async function clearSetupToken(): Promise<void> {
|
||||||
|
const path = getStatePath(TOKEN_FILE);
|
||||||
|
try {
|
||||||
|
await unlink(path);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
||||||
|
logger.warn('Failed to clear setup token', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For diagnostics / startup logging.
|
||||||
|
*/
|
||||||
|
export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> {
|
||||||
|
const path = getStatePath(TOKEN_FILE);
|
||||||
|
if (!existsSync(path)) return { exists: false, expiresInSeconds: null };
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
const payload = await readToken();
|
||||||
|
if (!payload) return { exists: false, expiresInSeconds: null };
|
||||||
|
const elapsed = Date.now() / 1000 - payload.issuedAt;
|
||||||
|
return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) };
|
||||||
|
} catch {
|
||||||
|
return { exists: false, expiresInSeconds: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
export interface StalwartCredentials {
|
export interface StalwartCredentials {
|
||||||
/** URL of the JMAP server (used for JMAP + management method calls) */
|
/** URL of the JMAP server (used for JMAP + management method calls) */
|
||||||
@@ -15,14 +16,16 @@ export interface StalwartCredentials {
|
|||||||
function parseSlot(raw: string | null): number | null {
|
function parseSlot(raw: string | null): number | null {
|
||||||
if (raw === null) return null;
|
if (raw === null) return null;
|
||||||
const slot = parseInt(raw, 10);
|
const slot = parseInt(raw, 10);
|
||||||
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
|
return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? null : slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALL_SLOTS = Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i);
|
||||||
|
|
||||||
function getCandidateSlots(request: NextRequest): number[] {
|
function getCandidateSlots(request: NextRequest): number[] {
|
||||||
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
|
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
|
||||||
?? parseSlot(request.nextUrl.searchParams.get('slot'));
|
?? parseSlot(request.nextUrl.searchParams.get('slot'));
|
||||||
|
|
||||||
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
|
return requestedSlot === null ? ALL_SLOTS : [requestedSlot];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ export function formatDateTime(
|
|||||||
return d.toLocaleString(undefined, localeOptions);
|
return d.toLocaleString(undefined, localeOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marketing emails pad the preheader with whitespace, format chars (soft
|
||||||
|
// hyphens, zero-width chars, BOM, directional marks) and combining marks
|
||||||
|
// (e.g. U+034F) to push real content past the preview window. Strip them all.
|
||||||
|
// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc.
|
||||||
|
const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u;
|
||||||
|
// After stripping, a server-side truncation indicator like "..." may be all
|
||||||
|
// that's left. Treat that as no preview so callers can fall back.
|
||||||
|
const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/;
|
||||||
|
|
||||||
|
export function stripInvisibleLeading(text: string): string {
|
||||||
|
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
|
||||||
|
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|
||||||
export function truncateText(text: string, maxLength: number): string {
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
return text.substring(0, maxLength).trim() + "...";
|
return text.substring(0, maxLength).trim() + "...";
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Komu: {recipients}"
|
"to": "Komu: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Odebrat subadresu",
|
"remove_sub_address": "Odebrat subadresu",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Přepsat",
|
||||||
|
"toggle_on": "Zrušit přepsání",
|
||||||
|
"toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu — mění se pouze viditelné záhlaví Od.",
|
||||||
|
"name_label": "Jméno odesílatele",
|
||||||
|
"name_placeholder": "Jméno",
|
||||||
|
"email_label": "E-mailová adresa odesílatele",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Šablona",
|
"use_template": "Šablona",
|
||||||
"save_as_template": "Uložit jako šablonu",
|
"save_as_template": "Uložit jako šablonu",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Rozdělené zobrazení",
|
"split": "Rozdělené zobrazení",
|
||||||
"split_description": "Seznam zpráv a panel pro čtení zůstávají viditelné vedle sebe.",
|
"split_description": "Seznam zpráv a panel pro čtení zůstávají viditelné vedle sebe.",
|
||||||
"focus": "Soustředěný seznam",
|
"focus": "Soustředěný seznam",
|
||||||
"focus_description": "Zobrazit jeden řádek na zprávu a otevřít poštu na plnou šířku s viditelným panelem složek."
|
"focus_description": "Zobrazit jeden řádek na zprávu a otevřít poštu na plnou šířku s viditelným panelem složek.",
|
||||||
|
"horizontal": "Čtecí panel dole",
|
||||||
|
"horizontal_description": "Zobrazit seznam zpráv nahoře a otevřít vybranou zprávu ve čtecím panelu pod ním."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Zobrazit náhledový text",
|
"label": "Zobrazit náhledový text",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "Automaticky vybírat adresu pro odpověď",
|
"label": "Automaticky vybírat adresu pro odpověď",
|
||||||
"description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu"
|
"description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Pozice podpisu",
|
||||||
|
"description": "Kam vložit podpis v odpovědích a přeposláních. Nad citovaným textem působí přirozeně jako zakončení odpovědi; pod ním zachovává původní zprávu vcelku.",
|
||||||
|
"above_quote": "Před citovaným textem",
|
||||||
|
"below_quote": "Za citovaným textem"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Oddělovač sub-adresy",
|
"label": "Oddělovač sub-adresy",
|
||||||
"description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).",
|
"description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nová podsložka...",
|
"new_subfolder": "Nová podsložka...",
|
||||||
"new_folder": "Nová složka...",
|
"new_folder": "Nová složka...",
|
||||||
"rename": "Přejmenovat...",
|
"rename": "Přejmenovat...",
|
||||||
|
"import_email": "Importovat .eml...",
|
||||||
"empty_folder": "Vyprázdnit složku",
|
"empty_folder": "Vyprázdnit složku",
|
||||||
"empty_folder_generic": "Vyprázdnit složku",
|
"empty_folder_generic": "Vyprázdnit složku",
|
||||||
"delete_folder": "Smazat složku",
|
"delete_folder": "Smazat složku",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "An: {recipients}"
|
"to": "An: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Sub-Adresse entfernen",
|
"remove_sub_address": "Sub-Adresse entfernen",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Überschreiben",
|
||||||
|
"toggle_on": "Überschreibung aufheben",
|
||||||
|
"toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet — nur die sichtbare Absenderkopfzeile ändert sich.",
|
||||||
|
"name_label": "Absendername",
|
||||||
|
"name_placeholder": "Name",
|
||||||
|
"email_label": "Absender-E-Mail-Adresse",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Vorlage",
|
"use_template": "Vorlage",
|
||||||
"save_as_template": "Als Vorlage speichern",
|
"save_as_template": "Als Vorlage speichern",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Geteilter Bereich",
|
"split": "Geteilter Bereich",
|
||||||
"split_description": "Nachrichtenliste und Lesebereich nebeneinander sichtbar halten.",
|
"split_description": "Nachrichtenliste und Lesebereich nebeneinander sichtbar halten.",
|
||||||
"focus": "Fokussierte Liste",
|
"focus": "Fokussierte Liste",
|
||||||
"focus_description": "Eine Zeile pro Nachricht anzeigen und E-Mails in voller Breite öffnen, während die Ordner-Seitenleiste sichtbar bleibt."
|
"focus_description": "Eine Zeile pro Nachricht anzeigen und E-Mails in voller Breite öffnen, während die Ordner-Seitenleiste sichtbar bleibt.",
|
||||||
|
"horizontal": "Lesebereich unten",
|
||||||
|
"horizontal_description": "Nachrichtenliste oben anzeigen und ausgewählte Nachricht in einem darunter liegenden Lesebereich öffnen."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Vorschautext anzeigen",
|
"label": "Vorschautext anzeigen",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "Antwortadresse automatisch wählen",
|
"label": "Antwortadresse automatisch wählen",
|
||||||
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
|
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Signaturposition",
|
||||||
|
"description": "Wo Ihre Signatur in Antworten und Weiterleitungen eingefügt wird. Über dem zitierten Text liest sie sich natürlich als Abschluss der Antwort; darunter bleibt die ursprüngliche Nachricht zusammenhängend.",
|
||||||
|
"above_quote": "Vor zitiertem Text",
|
||||||
|
"below_quote": "Nach zitiertem Text"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Sub-Adress-Trennzeichen",
|
"label": "Sub-Adress-Trennzeichen",
|
||||||
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
|
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Neuer Unterordner...",
|
"new_subfolder": "Neuer Unterordner...",
|
||||||
"new_folder": "Neuer Ordner...",
|
"new_folder": "Neuer Ordner...",
|
||||||
"rename": "Umbenennen...",
|
"rename": "Umbenennen...",
|
||||||
|
"import_email": ".eml importieren...",
|
||||||
"empty_folder": "Ordner leeren",
|
"empty_folder": "Ordner leeren",
|
||||||
"empty_folder_generic": "Ordner leeren",
|
"empty_folder_generic": "Ordner leeren",
|
||||||
"delete_folder": "Ordner löschen",
|
"delete_folder": "Ordner löschen",
|
||||||
|
|||||||
+22
-4
@@ -531,6 +531,15 @@
|
|||||||
"to": "To: {recipients}"
|
"to": "To: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Remove sub-address",
|
"remove_sub_address": "Remove sub-address",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Override",
|
||||||
|
"toggle_on": "Cancel override",
|
||||||
|
"toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity — only the visible From header changes.",
|
||||||
|
"name_label": "From name",
|
||||||
|
"name_placeholder": "Name",
|
||||||
|
"email_label": "From email address",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Template",
|
"use_template": "Template",
|
||||||
"save_as_template": "Save as Template",
|
"save_as_template": "Save as Template",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -940,11 +949,13 @@
|
|||||||
},
|
},
|
||||||
"mail_layout": {
|
"mail_layout": {
|
||||||
"label": "Mail Layout",
|
"label": "Mail Layout",
|
||||||
"description": "Choose between the classic split reading pane and a Gmail-style focused reading flow.",
|
"description": "Choose between the classic split reading pane, a Gmail-style focused reading flow, or a Zimbra-style bottom reading pane.",
|
||||||
"split": "Split pane",
|
"split": "Split pane",
|
||||||
"split_description": "Keep the message list and reading pane visible side by side.",
|
"split_description": "Keep the message list and reading pane visible side by side.",
|
||||||
"focus": "Focused list",
|
"focus": "Focused list",
|
||||||
"focus_description": "Show one line per message and open mail full-width while keeping the folder sidebar visible."
|
"focus_description": "Show one line per message and open mail full-width while keeping the folder sidebar visible.",
|
||||||
|
"horizontal": "Reading pane at bottom",
|
||||||
|
"horizontal_description": "Show the message list on top and open the selected message in a reading pane below it."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Show Preview Text",
|
"label": "Show Preview Text",
|
||||||
@@ -960,8 +971,14 @@
|
|||||||
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
|
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
|
||||||
},
|
},
|
||||||
"auto_select_reply_identity": {
|
"auto_select_reply_identity": {
|
||||||
"label": "Auto-select Reply Address",
|
"label": "Reply From Received Address",
|
||||||
"description": "When replying, automatically switch the From address to the identity that originally received the message"
|
"description": "When replying, send from the address the message was originally sent to. Matches identities first; for domain catch-all deliveries, rewrites the From header to the alias while sending through your primary identity."
|
||||||
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Signature Position",
|
||||||
|
"description": "Where to insert your signature in replies and forwards. Above the quoted text reads naturally as a closing for the reply; below keeps the original message contiguous.",
|
||||||
|
"above_quote": "Before quoted text",
|
||||||
|
"below_quote": "After quoted text"
|
||||||
},
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Sub-Address Delimiter",
|
"label": "Sub-Address Delimiter",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "New subfolder...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "New folder...",
|
||||||
"rename": "Rename...",
|
"rename": "Rename...",
|
||||||
|
"import_email": "Import .eml...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Empty folder",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Empty folder",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Delete folder",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Para: {recipients}"
|
"to": "Para: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Eliminar sub-dirección",
|
"remove_sub_address": "Eliminar sub-dirección",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Anular",
|
||||||
|
"toggle_on": "Cancelar anulación",
|
||||||
|
"toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad — solo cambia el encabezado De visible.",
|
||||||
|
"name_label": "Nombre del remitente",
|
||||||
|
"name_placeholder": "Nombre",
|
||||||
|
"email_label": "Dirección de correo del remitente",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Plantilla",
|
"use_template": "Plantilla",
|
||||||
"save_as_template": "Guardar como plantilla",
|
"save_as_template": "Guardar como plantilla",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Panel dividido",
|
"split": "Panel dividido",
|
||||||
"split_description": "Mantener la lista de mensajes y el panel de lectura visibles uno al lado del otro.",
|
"split_description": "Mantener la lista de mensajes y el panel de lectura visibles uno al lado del otro.",
|
||||||
"focus": "Lista enfocada",
|
"focus": "Lista enfocada",
|
||||||
"focus_description": "Mostrar una línea por mensaje y abrir el correo a ancho completo manteniendo visible la barra lateral de carpetas."
|
"focus_description": "Mostrar una línea por mensaje y abrir el correo a ancho completo manteniendo visible la barra lateral de carpetas.",
|
||||||
|
"horizontal": "Panel de lectura abajo",
|
||||||
|
"horizontal_description": "Muestra la lista de mensajes arriba y abre el mensaje seleccionado en un panel de lectura debajo."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Desactivar agrupación de conversaciones",
|
"label": "Desactivar agrupación de conversaciones",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Seleccionar dirección de respuesta automáticamente",
|
"label": "Seleccionar dirección de respuesta automáticamente",
|
||||||
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
|
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Posición de la firma",
|
||||||
|
"description": "Dónde insertar tu firma en respuestas y reenvíos. Encima del texto citado se lee de forma natural como cierre de la respuesta; debajo mantiene el mensaje original contiguo.",
|
||||||
|
"above_quote": "Antes del texto citado",
|
||||||
|
"below_quote": "Después del texto citado"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Delimitador de sub-dirección",
|
"label": "Delimitador de sub-dirección",
|
||||||
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
|
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nueva subcarpeta...",
|
"new_subfolder": "Nueva subcarpeta...",
|
||||||
"new_folder": "Nueva carpeta...",
|
"new_folder": "Nueva carpeta...",
|
||||||
"rename": "Renombrar...",
|
"rename": "Renombrar...",
|
||||||
|
"import_email": "Importar .eml...",
|
||||||
"empty_folder": "Vaciar carpeta",
|
"empty_folder": "Vaciar carpeta",
|
||||||
"empty_folder_generic": "Vaciar carpeta",
|
"empty_folder_generic": "Vaciar carpeta",
|
||||||
"delete_folder": "Eliminar carpeta",
|
"delete_folder": "Eliminar carpeta",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "À : {recipients}"
|
"to": "À : {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Retirer le sous-adressage",
|
"remove_sub_address": "Retirer le sous-adressage",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Remplacer",
|
||||||
|
"toggle_on": "Annuler le remplacement",
|
||||||
|
"toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité — seul l'en-tête De visible change.",
|
||||||
|
"name_label": "Nom de l'expéditeur",
|
||||||
|
"name_placeholder": "Nom",
|
||||||
|
"email_label": "Adresse e-mail de l'expéditeur",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Modèle",
|
"use_template": "Modèle",
|
||||||
"save_as_template": "Enregistrer comme modèle",
|
"save_as_template": "Enregistrer comme modèle",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Volet divisé",
|
"split": "Volet divisé",
|
||||||
"split_description": "Garder la liste des messages et le volet de lecture visibles côte à côte.",
|
"split_description": "Garder la liste des messages et le volet de lecture visibles côte à côte.",
|
||||||
"focus": "Liste focalisée",
|
"focus": "Liste focalisée",
|
||||||
"focus_description": "Afficher une ligne par message et ouvrir le courrier en pleine largeur tout en gardant la barre latérale des dossiers visible."
|
"focus_description": "Afficher une ligne par message et ouvrir le courrier en pleine largeur tout en gardant la barre latérale des dossiers visible.",
|
||||||
|
"horizontal": "Volet de lecture en bas",
|
||||||
|
"horizontal_description": "Afficher la liste des messages en haut et ouvrir le message sélectionné dans un volet de lecture en dessous."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Désactiver le regroupement par conversation",
|
"label": "Désactiver le regroupement par conversation",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Sélection automatique de l'adresse de réponse",
|
"label": "Sélection automatique de l'adresse de réponse",
|
||||||
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
|
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Position de la signature",
|
||||||
|
"description": "Où insérer votre signature dans les réponses et les transferts. Au-dessus du texte cité, elle se lit naturellement comme la conclusion de la réponse ; en dessous, elle garde le message d'origine contigu.",
|
||||||
|
"above_quote": "Avant le texte cité",
|
||||||
|
"below_quote": "Après le texte cité"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Délimiteur de sous-adresse",
|
"label": "Délimiteur de sous-adresse",
|
||||||
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
|
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nouveau sous-dossier...",
|
"new_subfolder": "Nouveau sous-dossier...",
|
||||||
"new_folder": "Nouveau dossier...",
|
"new_folder": "Nouveau dossier...",
|
||||||
"rename": "Renommer...",
|
"rename": "Renommer...",
|
||||||
|
"import_email": "Importer un .eml...",
|
||||||
"empty_folder": "Vider le dossier",
|
"empty_folder": "Vider le dossier",
|
||||||
"empty_folder_generic": "Vider le dossier",
|
"empty_folder_generic": "Vider le dossier",
|
||||||
"delete_folder": "Supprimer le dossier",
|
"delete_folder": "Supprimer le dossier",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "A: {recipients}"
|
"to": "A: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Rimuovi sotto-indirizzo",
|
"remove_sub_address": "Rimuovi sotto-indirizzo",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Sovrascrivi",
|
||||||
|
"toggle_on": "Annulla sovrascrittura",
|
||||||
|
"toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità — cambia solo l'intestazione Da visibile.",
|
||||||
|
"name_label": "Nome mittente",
|
||||||
|
"name_placeholder": "Nome",
|
||||||
|
"email_label": "Indirizzo email del mittente",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Modello",
|
"use_template": "Modello",
|
||||||
"save_as_template": "Salva come modello",
|
"save_as_template": "Salva come modello",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Pannello diviso",
|
"split": "Pannello diviso",
|
||||||
"split_description": "Mantieni la lista dei messaggi e il pannello di lettura visibili affiancati.",
|
"split_description": "Mantieni la lista dei messaggi e il pannello di lettura visibili affiancati.",
|
||||||
"focus": "Lista focalizzata",
|
"focus": "Lista focalizzata",
|
||||||
"focus_description": "Mostra una riga per messaggio e apri la posta a larghezza piena mantenendo visibile la barra laterale delle cartelle."
|
"focus_description": "Mostra una riga per messaggio e apri la posta a larghezza piena mantenendo visibile la barra laterale delle cartelle.",
|
||||||
|
"horizontal": "Riquadro di lettura in basso",
|
||||||
|
"horizontal_description": "Mostra l'elenco dei messaggi in alto e apri il messaggio selezionato in un riquadro di lettura sotto."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Disabilita raggruppamento conversazioni",
|
"label": "Disabilita raggruppamento conversazioni",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Seleziona automaticamente l'indirizzo di risposta",
|
"label": "Seleziona automaticamente l'indirizzo di risposta",
|
||||||
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
|
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Posizione della firma",
|
||||||
|
"description": "Dove inserire la tua firma nelle risposte e negli inoltri. Sopra il testo citato si legge in modo naturale come chiusura della risposta; sotto mantiene il messaggio originale contiguo.",
|
||||||
|
"above_quote": "Prima del testo citato",
|
||||||
|
"below_quote": "Dopo il testo citato"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Delimitatore sub-indirizzo",
|
"label": "Delimitatore sub-indirizzo",
|
||||||
"description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).",
|
"description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nuova sottocartella...",
|
"new_subfolder": "Nuova sottocartella...",
|
||||||
"new_folder": "Nuova cartella...",
|
"new_folder": "Nuova cartella...",
|
||||||
"rename": "Rinomina...",
|
"rename": "Rinomina...",
|
||||||
|
"import_email": "Importa .eml...",
|
||||||
"empty_folder": "Svuota cartella",
|
"empty_folder": "Svuota cartella",
|
||||||
"empty_folder_generic": "Svuota cartella",
|
"empty_folder_generic": "Svuota cartella",
|
||||||
"delete_folder": "Elimina cartella",
|
"delete_folder": "Elimina cartella",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "宛先: {recipients}"
|
"to": "宛先: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "サブアドレスを削除",
|
"remove_sub_address": "サブアドレスを削除",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "上書き",
|
||||||
|
"toggle_on": "上書きを取り消す",
|
||||||
|
"toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます — 表示される差出人ヘッダーのみが変更されます。",
|
||||||
|
"name_label": "差出人名",
|
||||||
|
"name_placeholder": "名前",
|
||||||
|
"email_label": "差出人メールアドレス",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "テンプレート",
|
"use_template": "テンプレート",
|
||||||
"save_as_template": "テンプレートとして保存",
|
"save_as_template": "テンプレートとして保存",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "分割ペイン",
|
"split": "分割ペイン",
|
||||||
"split_description": "メッセージリストと読み取りペインを並べて表示します。",
|
"split_description": "メッセージリストと読み取りペインを並べて表示します。",
|
||||||
"focus": "集中リスト",
|
"focus": "集中リスト",
|
||||||
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。"
|
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。",
|
||||||
|
"horizontal": "閲覧ペインを下に表示",
|
||||||
|
"horizontal_description": "メッセージ一覧を上に表示し、選択したメッセージを下の閲覧ペインで開きます。"
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "会話グループ化を無効にする",
|
"label": "会話グループ化を無効にする",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "返信元アドレスを自動選択",
|
"label": "返信元アドレスを自動選択",
|
||||||
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
|
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "署名の位置",
|
||||||
|
"description": "返信や転送で署名を挿入する位置。引用テキストの上は返信の締めとして自然に読めます。下は元のメッセージを続けて表示します。",
|
||||||
|
"above_quote": "引用テキストの前",
|
||||||
|
"below_quote": "引用テキストの後"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "サブアドレス区切り文字",
|
"label": "サブアドレス区切り文字",
|
||||||
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
|
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "新しいサブフォルダー...",
|
"new_subfolder": "新しいサブフォルダー...",
|
||||||
"new_folder": "新しいフォルダー...",
|
"new_folder": "新しいフォルダー...",
|
||||||
"rename": "名前を変更...",
|
"rename": "名前を変更...",
|
||||||
|
"import_email": ".eml をインポート...",
|
||||||
"empty_folder": "フォルダーを空にする",
|
"empty_folder": "フォルダーを空にする",
|
||||||
"empty_folder_generic": "フォルダーを空にする",
|
"empty_folder_generic": "フォルダーを空にする",
|
||||||
"delete_folder": "フォルダーを削除",
|
"delete_folder": "フォルダーを削除",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "받는 사람: {recipients}"
|
"to": "받는 사람: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "서브 어드레스 삭제",
|
"remove_sub_address": "서브 어드레스 삭제",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "재정의",
|
||||||
|
"toggle_on": "재정의 취소",
|
||||||
|
"toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 — 표시되는 보낸 사람 헤더만 변경됩니다.",
|
||||||
|
"name_label": "보낸 사람 이름",
|
||||||
|
"name_placeholder": "이름",
|
||||||
|
"email_label": "보낸 사람 이메일 주소",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "템플릿",
|
"use_template": "템플릿",
|
||||||
"save_as_template": "템플릿으로 저장",
|
"save_as_template": "템플릿으로 저장",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "화면 분할",
|
"split": "화면 분할",
|
||||||
"split_description": "메일 목록과 읽기 창을 나란히 보여줘요.",
|
"split_description": "메일 목록과 읽기 창을 나란히 보여줘요.",
|
||||||
"focus": "집중형 목록",
|
"focus": "집중형 목록",
|
||||||
"focus_description": "메일을 한 줄로 보여주고, 클릭하면 폴더 사이드바는 남겨둔 채 메일 내용을 넓게 보여줘요."
|
"focus_description": "메일을 한 줄로 보여주고, 클릭하면 폴더 사이드바는 남겨둔 채 메일 내용을 넓게 보여줘요.",
|
||||||
|
"horizontal": "읽기 창 하단 표시",
|
||||||
|
"horizontal_description": "메시지 목록을 상단에 표시하고 선택한 메시지를 하단 읽기 창에서 엽니다."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "미리보기 텍스트 표시",
|
"label": "미리보기 텍스트 표시",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "답장 시 보내는 사람 자동 선택",
|
"label": "답장 시 보내는 사람 자동 선택",
|
||||||
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요"
|
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "서명 위치",
|
||||||
|
"description": "답장과 전달에서 서명을 삽입할 위치. 인용된 텍스트 위에 두면 답장의 마무리처럼 자연스럽게 읽히고, 아래에 두면 원본 메시지가 이어져 보입니다.",
|
||||||
|
"above_quote": "인용 텍스트 앞",
|
||||||
|
"below_quote": "인용 텍스트 뒤"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "서브 주소 구분자",
|
"label": "서브 주소 구분자",
|
||||||
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
|
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "새 하위 폴더...",
|
"new_subfolder": "새 하위 폴더...",
|
||||||
"new_folder": "새 폴더...",
|
"new_folder": "새 폴더...",
|
||||||
"rename": "이름 바꾸기...",
|
"rename": "이름 바꾸기...",
|
||||||
|
"import_email": ".eml 가져오기...",
|
||||||
"empty_folder": "폴더 비우기",
|
"empty_folder": "폴더 비우기",
|
||||||
"empty_folder_generic": "폴더 비우기",
|
"empty_folder_generic": "폴더 비우기",
|
||||||
"delete_folder": "폴더 삭제",
|
"delete_folder": "폴더 삭제",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Kam: {recipients}"
|
"to": "Kam: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Noņemt apakšadresi",
|
"remove_sub_address": "Noņemt apakšadresi",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Pārrakstīt",
|
||||||
|
"toggle_on": "Atcelt pārrakstīšanu",
|
||||||
|
"toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti — mainās tikai redzamais No galvenes ieraksts.",
|
||||||
|
"name_label": "Sūtītāja vārds",
|
||||||
|
"name_placeholder": "Vārds",
|
||||||
|
"email_label": "Sūtītāja e-pasta adrese",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Veidne",
|
"use_template": "Veidne",
|
||||||
"save_as_template": "Saglabāt kā veidni",
|
"save_as_template": "Saglabāt kā veidni",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Dalīts skats",
|
"split": "Dalīts skats",
|
||||||
"split_description": "Ziņojumu saraksts un lasīšanas rūts ir redzamas blakus.",
|
"split_description": "Ziņojumu saraksts un lasīšanas rūts ir redzamas blakus.",
|
||||||
"focus": "Fokusa saraksts",
|
"focus": "Fokusa saraksts",
|
||||||
"focus_description": "Rādīt vienu rindu katram ziņojumam un atvērt vēstuli pilnā platumā."
|
"focus_description": "Rādīt vienu rindu katram ziņojumam un atvērt vēstuli pilnā platumā.",
|
||||||
|
"horizontal": "Lasīšanas rūts apakšā",
|
||||||
|
"horizontal_description": "Rādīt ziņojumu sarakstu augšā un atvērt atlasīto ziņojumu lasīšanas rūtī zem tā."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Izslēgt sarunu grupēšanu",
|
"label": "Izslēgt sarunu grupēšanu",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Automātiski izvēlēties atbildes adresi",
|
"label": "Automātiski izvēlēties atbildes adresi",
|
||||||
"description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta"
|
"description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Paraksta novietojums",
|
||||||
|
"description": "Kur ievietot jūsu parakstu atbildēs un pārsūtīšanā. Virs citētā teksta tas dabiski lasās kā atbildes noslēgums; zem tā saglabā oriģinālo ziņojumu vienkopus.",
|
||||||
|
"above_quote": "Pirms citētā teksta",
|
||||||
|
"below_quote": "Pēc citētā teksta"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Apakšadreses atdalītājs",
|
"label": "Apakšadreses atdalītājs",
|
||||||
"description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).",
|
"description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Jauna apakšmape...",
|
"new_subfolder": "Jauna apakšmape...",
|
||||||
"new_folder": "Jauna mape...",
|
"new_folder": "Jauna mape...",
|
||||||
"rename": "Pārsaukt...",
|
"rename": "Pārsaukt...",
|
||||||
|
"import_email": "Importēt .eml...",
|
||||||
"empty_folder": "Iztukšot mapi",
|
"empty_folder": "Iztukšot mapi",
|
||||||
"empty_folder_generic": "Iztukšot mapi",
|
"empty_folder_generic": "Iztukšot mapi",
|
||||||
"delete_folder": "Dzēst mapi",
|
"delete_folder": "Dzēst mapi",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Aan: {recipients}"
|
"to": "Aan: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Sub-adres verwijderen",
|
"remove_sub_address": "Sub-adres verwijderen",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Overschrijven",
|
||||||
|
"toggle_on": "Overschrijven annuleren",
|
||||||
|
"toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden — alleen de zichtbare Van-koptekst verandert.",
|
||||||
|
"name_label": "Afzendernaam",
|
||||||
|
"name_placeholder": "Naam",
|
||||||
|
"email_label": "E-mailadres afzender",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Sjabloon",
|
"use_template": "Sjabloon",
|
||||||
"save_as_template": "Opslaan als sjabloon",
|
"save_as_template": "Opslaan als sjabloon",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Gesplitst venster",
|
"split": "Gesplitst venster",
|
||||||
"split_description": "Houd de berichtenlijst en het leesvenster naast elkaar zichtbaar.",
|
"split_description": "Houd de berichtenlijst en het leesvenster naast elkaar zichtbaar.",
|
||||||
"focus": "Gefocuste lijst",
|
"focus": "Gefocuste lijst",
|
||||||
"focus_description": "Toon één regel per bericht en open e-mail op volledige breedte terwijl de mappenzijbalk zichtbaar blijft."
|
"focus_description": "Toon één regel per bericht en open e-mail op volledige breedte terwijl de mappenzijbalk zichtbaar blijft.",
|
||||||
|
"horizontal": "Leesvenster onderaan",
|
||||||
|
"horizontal_description": "Toon de berichtenlijst bovenaan en open het geselecteerde bericht in een leesvenster eronder."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Conversatiegroepering uitschakelen",
|
"label": "Conversatiegroepering uitschakelen",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Antwoordadres automatisch selecteren",
|
"label": "Antwoordadres automatisch selecteren",
|
||||||
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
|
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Positie van handtekening",
|
||||||
|
"description": "Waar je handtekening in antwoorden en doorgestuurde berichten moet worden ingevoegd. Boven de geciteerde tekst leest natuurlijk als afsluiting van het antwoord; eronder houdt het originele bericht aaneengesloten.",
|
||||||
|
"above_quote": "Voor geciteerde tekst",
|
||||||
|
"below_quote": "Na geciteerde tekst"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Sub-adres scheidingsteken",
|
"label": "Sub-adres scheidingsteken",
|
||||||
"description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).",
|
"description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nieuwe submap...",
|
"new_subfolder": "Nieuwe submap...",
|
||||||
"new_folder": "Nieuwe map...",
|
"new_folder": "Nieuwe map...",
|
||||||
"rename": "Hernoemen...",
|
"rename": "Hernoemen...",
|
||||||
|
"import_email": ".eml importeren...",
|
||||||
"empty_folder": "Map leegmaken",
|
"empty_folder": "Map leegmaken",
|
||||||
"empty_folder_generic": "Map leegmaken",
|
"empty_folder_generic": "Map leegmaken",
|
||||||
"delete_folder": "Map verwijderen",
|
"delete_folder": "Map verwijderen",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Do: {recipients}"
|
"to": "Do: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Usuń podadres",
|
"remove_sub_address": "Usuń podadres",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Zastąp",
|
||||||
|
"toggle_on": "Anuluj zastąpienie",
|
||||||
|
"toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość — zmienia się tylko widoczny nagłówek Od.",
|
||||||
|
"name_label": "Nazwa nadawcy",
|
||||||
|
"name_placeholder": "Nazwa",
|
||||||
|
"email_label": "Adres e-mail nadawcy",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Szablon",
|
"use_template": "Szablon",
|
||||||
"save_as_template": "Zapisz jako szablon",
|
"save_as_template": "Zapisz jako szablon",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Widok podzielony",
|
"split": "Widok podzielony",
|
||||||
"split_description": "Lista wiadomości i panel czytania pozostają widoczne obok siebie.",
|
"split_description": "Lista wiadomości i panel czytania pozostają widoczne obok siebie.",
|
||||||
"focus": "Skupiona lista",
|
"focus": "Skupiona lista",
|
||||||
"focus_description": "Pokazuj jeden wiersz na wiadomość i otwieraj pocztę na pełną szerokość, pozostawiając widoczny pasek folderów."
|
"focus_description": "Pokazuj jeden wiersz na wiadomość i otwieraj pocztę na pełną szerokość, pozostawiając widoczny pasek folderów.",
|
||||||
|
"horizontal": "Okienko czytania na dole",
|
||||||
|
"horizontal_description": "Pokaż listę wiadomości u góry i otwórz wybraną wiadomość w okienku czytania pod nią."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Pokaż tekst podglądu",
|
"label": "Pokaż tekst podglądu",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "Automatycznie wybieraj adres odpowiedzi",
|
"label": "Automatycznie wybieraj adres odpowiedzi",
|
||||||
"description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość"
|
"description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Pozycja podpisu",
|
||||||
|
"description": "Gdzie wstawić podpis w odpowiedziach i wiadomościach przekazanych dalej. Nad cytowanym tekstem brzmi naturalnie jako zakończenie odpowiedzi; pod nim zachowuje oryginalną wiadomość w całości.",
|
||||||
|
"above_quote": "Przed cytowanym tekstem",
|
||||||
|
"below_quote": "Po cytowanym tekście"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Separator sub-adresu",
|
"label": "Separator sub-adresu",
|
||||||
"description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).",
|
"description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nowy podfolder...",
|
"new_subfolder": "Nowy podfolder...",
|
||||||
"new_folder": "Nowy folder...",
|
"new_folder": "Nowy folder...",
|
||||||
"rename": "Zmień nazwę...",
|
"rename": "Zmień nazwę...",
|
||||||
|
"import_email": "Importuj .eml...",
|
||||||
"empty_folder": "Opróżnij folder",
|
"empty_folder": "Opróżnij folder",
|
||||||
"empty_folder_generic": "Opróżnij folder",
|
"empty_folder_generic": "Opróżnij folder",
|
||||||
"delete_folder": "Usuń folder",
|
"delete_folder": "Usuń folder",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Para: {recipients}"
|
"to": "Para: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Remover sub-endereço",
|
"remove_sub_address": "Remover sub-endereço",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Substituir",
|
||||||
|
"toggle_on": "Cancelar substituição",
|
||||||
|
"toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade — apenas o cabeçalho De visível muda.",
|
||||||
|
"name_label": "Nome do remetente",
|
||||||
|
"name_placeholder": "Nome",
|
||||||
|
"email_label": "Endereço de email do remetente",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Modelo",
|
"use_template": "Modelo",
|
||||||
"save_as_template": "Salvar como modelo",
|
"save_as_template": "Salvar como modelo",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Painel dividido",
|
"split": "Painel dividido",
|
||||||
"split_description": "Manter a lista de mensagens e o painel de leitura visíveis lado a lado.",
|
"split_description": "Manter a lista de mensagens e o painel de leitura visíveis lado a lado.",
|
||||||
"focus": "Lista focada",
|
"focus": "Lista focada",
|
||||||
"focus_description": "Mostrar uma linha por mensagem e abrir o e-mail em largura total mantendo a barra lateral de pastas visível."
|
"focus_description": "Mostrar uma linha por mensagem e abrir o e-mail em largura total mantendo a barra lateral de pastas visível.",
|
||||||
|
"horizontal": "Painel de leitura em baixo",
|
||||||
|
"horizontal_description": "Mostre a lista de mensagens em cima e abra a mensagem selecionada num painel de leitura abaixo."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Desativar agrupamento de conversas",
|
"label": "Desativar agrupamento de conversas",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Selecionar automaticamente o endereço de resposta",
|
"label": "Selecionar automaticamente o endereço de resposta",
|
||||||
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
|
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Posição da assinatura",
|
||||||
|
"description": "Onde inserir a sua assinatura em respostas e encaminhamentos. Acima do texto citado lê-se naturalmente como fecho da resposta; abaixo mantém a mensagem original contígua.",
|
||||||
|
"above_quote": "Antes do texto citado",
|
||||||
|
"below_quote": "Depois do texto citado"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Delimitador de sub-endereço",
|
"label": "Delimitador de sub-endereço",
|
||||||
"description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).",
|
"description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Nova subpasta...",
|
"new_subfolder": "Nova subpasta...",
|
||||||
"new_folder": "Nova pasta...",
|
"new_folder": "Nova pasta...",
|
||||||
"rename": "Renomear...",
|
"rename": "Renomear...",
|
||||||
|
"import_email": "Importar .eml...",
|
||||||
"empty_folder": "Esvaziar pasta",
|
"empty_folder": "Esvaziar pasta",
|
||||||
"empty_folder_generic": "Esvaziar pasta",
|
"empty_folder_generic": "Esvaziar pasta",
|
||||||
"delete_folder": "Excluir pasta",
|
"delete_folder": "Excluir pasta",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Кому: {recipients}"
|
"to": "Кому: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Удалить суб-адрес",
|
"remove_sub_address": "Удалить суб-адрес",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Переопределить",
|
||||||
|
"toggle_on": "Отменить переопределение",
|
||||||
|
"toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись — меняется только видимый заголовок От.",
|
||||||
|
"name_label": "Имя отправителя",
|
||||||
|
"name_placeholder": "Имя",
|
||||||
|
"email_label": "Email отправителя",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Шаблон",
|
"use_template": "Шаблон",
|
||||||
"save_as_template": "Сохранить как шаблон",
|
"save_as_template": "Сохранить как шаблон",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Разделённая панель",
|
"split": "Разделённая панель",
|
||||||
"split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.",
|
"split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.",
|
||||||
"focus": "Сфокусированный список",
|
"focus": "Сфокусированный список",
|
||||||
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок."
|
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок.",
|
||||||
|
"horizontal": "Область чтения снизу",
|
||||||
|
"horizontal_description": "Показывать список сообщений сверху и открывать выбранное сообщение в области чтения под ним."
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Отключить группировку по беседам",
|
"label": "Отключить группировку по беседам",
|
||||||
@@ -955,6 +966,12 @@
|
|||||||
"label": "Автоматически выбирать адрес для ответа",
|
"label": "Автоматически выбирать адрес для ответа",
|
||||||
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
|
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Положение подписи",
|
||||||
|
"description": "Куда вставлять подпись в ответах и пересылке. Над цитируемым текстом она читается естественно как завершение ответа; под ним сохраняет целостность исходного сообщения.",
|
||||||
|
"above_quote": "Перед цитируемым текстом",
|
||||||
|
"below_quote": "После цитируемого текста"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Разделитель суб-адресов",
|
"label": "Разделитель суб-адресов",
|
||||||
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
|
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Новая вложенная папка...",
|
"new_subfolder": "Новая вложенная папка...",
|
||||||
"new_folder": "Новая папка...",
|
"new_folder": "Новая папка...",
|
||||||
"rename": "Переименовать...",
|
"rename": "Переименовать...",
|
||||||
|
"import_email": "Импортировать .eml...",
|
||||||
"empty_folder": "Очистить папку",
|
"empty_folder": "Очистить папку",
|
||||||
"empty_folder_generic": "Очистить папку",
|
"empty_folder_generic": "Очистить папку",
|
||||||
"delete_folder": "Удалить папку",
|
"delete_folder": "Удалить папку",
|
||||||
|
|||||||
+19
-1
@@ -531,6 +531,15 @@
|
|||||||
"to": "Kime: {recipients}"
|
"to": "Kime: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Alt adresi kaldır",
|
"remove_sub_address": "Alt adresi kaldır",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Geçersiz kıl",
|
||||||
|
"toggle_on": "Geçersiz kılmayı iptal et",
|
||||||
|
"toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir — yalnızca görünür Kimden başlığı değişir.",
|
||||||
|
"name_label": "Gönderen adı",
|
||||||
|
"name_placeholder": "Ad",
|
||||||
|
"email_label": "Gönderen e-posta adresi",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Şablon",
|
"use_template": "Şablon",
|
||||||
"save_as_template": "Şablon Olarak Kaydet",
|
"save_as_template": "Şablon Olarak Kaydet",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Bölünmüş bölme",
|
"split": "Bölünmüş bölme",
|
||||||
"split_description": "İleti listesi ve okuma bölmesini yan yana görünür tutun.",
|
"split_description": "İleti listesi ve okuma bölmesini yan yana görünür tutun.",
|
||||||
"focus": "Odaklı liste",
|
"focus": "Odaklı liste",
|
||||||
"focus_description": "İleti başına bir satır gösterin ve klasör kenar çubuğu görünürken postayı tam genişlikte açın."
|
"focus_description": "İleti başına bir satır gösterin ve klasör kenar çubuğu görünürken postayı tam genişlikte açın.",
|
||||||
|
"horizontal": "Okuma bölmesi altta",
|
||||||
|
"horizontal_description": "Mesaj listesini üstte gösterin ve seçilen mesajı altındaki okuma bölmesinde açın."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Önizleme Metnini Göster",
|
"label": "Önizleme Metnini Göster",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "Yanıt Adresini Otomatik Seç",
|
"label": "Yanıt Adresini Otomatik Seç",
|
||||||
"description": "Yanıtlarken, Kimden adresini iletiyi başlangıçta alan kimliğe otomatik olarak değiştir"
|
"description": "Yanıtlarken, Kimden adresini iletiyi başlangıçta alan kimliğe otomatik olarak değiştir"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "İmza konumu",
|
||||||
|
"description": "Yanıtlarda ve iletmelerde imzanızın nereye ekleneceği. Alıntılanan metnin üzerinde, yanıt için doğal bir kapanış olarak okunur; altında ise orijinal mesajı bir bütün hâlinde tutar.",
|
||||||
|
"above_quote": "Alıntılanan metinden önce",
|
||||||
|
"below_quote": "Alıntılanan metinden sonra"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Alt Adres Ayırıcı",
|
"label": "Alt Adres Ayırıcı",
|
||||||
"description": "Kullanıcı adını alt adres etiketinden ayıran karakter. Posta sunucunuzun kullandığı ayırıcıyı seçin (ör. kullanici{delimiter}etiket@domain.com).",
|
"description": "Kullanıcı adını alt adres etiketinden ayıran karakter. Posta sunucunuzun kullandığı ayırıcıyı seçin (ör. kullanici{delimiter}etiket@domain.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Yeni alt klasör...",
|
"new_subfolder": "Yeni alt klasör...",
|
||||||
"new_folder": "Yeni klasör...",
|
"new_folder": "Yeni klasör...",
|
||||||
"rename": "Yeniden adlandır...",
|
"rename": "Yeniden adlandır...",
|
||||||
|
"import_email": ".eml içe aktar...",
|
||||||
"empty_folder": "Klasörü boşalt",
|
"empty_folder": "Klasörü boşalt",
|
||||||
"empty_folder_generic": "Klasörü boşalt",
|
"empty_folder_generic": "Klasörü boşalt",
|
||||||
"delete_folder": "Klasörü sil",
|
"delete_folder": "Klasörü sil",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "Кому: {recipients}"
|
"to": "Кому: {recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "Видалити підадресу",
|
"remove_sub_address": "Видалити підадресу",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "Замінити",
|
||||||
|
"toggle_on": "Скасувати заміну",
|
||||||
|
"toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність — змінюється лише видимий заголовок Від.",
|
||||||
|
"name_label": "Ім'я відправника",
|
||||||
|
"name_placeholder": "Ім'я",
|
||||||
|
"email_label": "Електронна адреса відправника",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "Шаблон",
|
"use_template": "Шаблон",
|
||||||
"save_as_template": "Зберегти як шаблон",
|
"save_as_template": "Зберегти як шаблон",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "Розділена панель",
|
"split": "Розділена панель",
|
||||||
"split_description": "Тримайте список повідомлень і панель читання видимими поруч.",
|
"split_description": "Тримайте список повідомлень і панель читання видимими поруч.",
|
||||||
"focus": "Сфокусований список",
|
"focus": "Сфокусований список",
|
||||||
"focus_description": "Показувати один рядок на повідомлення та відкривати пошту на всю ширину, зберігаючи бічну панель папки видимою."
|
"focus_description": "Показувати один рядок на повідомлення та відкривати пошту на всю ширину, зберігаючи бічну панель папки видимою.",
|
||||||
|
"horizontal": "Область читання знизу",
|
||||||
|
"horizontal_description": "Показуйте список повідомлень угорі та відкривайте вибране повідомлення в області читання під ним."
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Показати попередній перегляд тексту",
|
"label": "Показати попередній перегляд тексту",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "Автоматичний вибір адреси для відповіді",
|
"label": "Автоматичний вибір адреси для відповіді",
|
||||||
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення"
|
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "Розташування підпису",
|
||||||
|
"description": "Куди вставляти підпис у відповідях і пересиланнях. Над цитованим текстом читається природно як завершення відповіді; під ним зберігає цілісність оригінального повідомлення.",
|
||||||
|
"above_quote": "Перед цитованим текстом",
|
||||||
|
"below_quote": "Після цитованого тексту"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "Розділювач під-адреси",
|
"label": "Розділювач під-адреси",
|
||||||
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
|
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "Нова вкладена папка...",
|
"new_subfolder": "Нова вкладена папка...",
|
||||||
"new_folder": "Нова папка...",
|
"new_folder": "Нова папка...",
|
||||||
"rename": "Перейменувати...",
|
"rename": "Перейменувати...",
|
||||||
|
"import_email": "Імпортувати .eml...",
|
||||||
"empty_folder": "Очистити папку",
|
"empty_folder": "Очистити папку",
|
||||||
"empty_folder_generic": "Очистити папку",
|
"empty_folder_generic": "Очистити папку",
|
||||||
"delete_folder": "Видалити папку",
|
"delete_folder": "Видалити папку",
|
||||||
|
|||||||
+19
-1
@@ -528,6 +528,15 @@
|
|||||||
"to": "至:{recipients}"
|
"to": "至:{recipients}"
|
||||||
},
|
},
|
||||||
"remove_sub_address": "删除子地址",
|
"remove_sub_address": "删除子地址",
|
||||||
|
"from_override": {
|
||||||
|
"toggle_off": "覆盖",
|
||||||
|
"toggle_on": "取消覆盖",
|
||||||
|
"toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 — 仅可见的发件人标题发生变化。",
|
||||||
|
"name_label": "发件人姓名",
|
||||||
|
"name_placeholder": "姓名",
|
||||||
|
"email_label": "发件人电子邮件地址",
|
||||||
|
"email_placeholder": "alias@example.com"
|
||||||
|
},
|
||||||
"use_template": "模板",
|
"use_template": "模板",
|
||||||
"save_as_template": "保存为模板",
|
"save_as_template": "保存为模板",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -941,7 +950,9 @@
|
|||||||
"split": "分栏视图",
|
"split": "分栏视图",
|
||||||
"split_description": "左侧显示邮件列表,右侧显示阅读窗格。",
|
"split_description": "左侧显示邮件列表,右侧显示阅读窗格。",
|
||||||
"focus": "沉浸模式",
|
"focus": "沉浸模式",
|
||||||
"focus_description": "每封邮件显示一行,打开邮件时使用全宽阅读视图,同时保留文件夹侧边栏。"
|
"focus_description": "每封邮件显示一行,打开邮件时使用全宽阅读视图,同时保留文件夹侧边栏。",
|
||||||
|
"horizontal": "底部阅读窗格",
|
||||||
|
"horizontal_description": "在顶部显示邮件列表,并在下方的阅读窗格中打开所选邮件。"
|
||||||
},
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "显示预览文本",
|
"label": "显示预览文本",
|
||||||
@@ -960,6 +971,12 @@
|
|||||||
"label": "自动选择回复地址",
|
"label": "自动选择回复地址",
|
||||||
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份"
|
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份"
|
||||||
},
|
},
|
||||||
|
"signature_position": {
|
||||||
|
"label": "签名位置",
|
||||||
|
"description": "在回复和转发中插入签名的位置。位于引用文本上方时,可作为回复的自然结尾;位于下方时,保持原始邮件连贯。",
|
||||||
|
"above_quote": "引用文本之前",
|
||||||
|
"below_quote": "引用文本之后"
|
||||||
|
},
|
||||||
"sub_address_delimiter": {
|
"sub_address_delimiter": {
|
||||||
"label": "子地址分隔符",
|
"label": "子地址分隔符",
|
||||||
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
|
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
|
||||||
@@ -1684,6 +1701,7 @@
|
|||||||
"new_subfolder": "新建子文件夹...",
|
"new_subfolder": "新建子文件夹...",
|
||||||
"new_folder": "新建文件夹...",
|
"new_folder": "新建文件夹...",
|
||||||
"rename": "重命名...",
|
"rename": "重命名...",
|
||||||
|
"import_email": "导入 .eml...",
|
||||||
"empty_folder": "清空文件夹",
|
"empty_folder": "清空文件夹",
|
||||||
"empty_folder_generic": "清空文件夹",
|
"empty_folder_generic": "清空文件夹",
|
||||||
"delete_folder": "删除文件夹",
|
"delete_folder": "删除文件夹",
|
||||||
|
|||||||
Generated
+42
-42
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.2",
|
"version": "1.6.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.2",
|
"version": "1.6.4",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.6",
|
||||||
"next-intl": "^4.9.1",
|
"next-intl": "^4.9.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"pkijs": "^3.4.0",
|
"pkijs": "^3.4.0",
|
||||||
@@ -1910,15 +1910,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
|
||||||
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
|
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
||||||
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
|
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1932,9 +1932,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
||||||
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
|
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1948,9 +1948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
||||||
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
|
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1964,9 +1964,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
||||||
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
|
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1980,9 +1980,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
||||||
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
|
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1996,9 +1996,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
||||||
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
|
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2012,9 +2012,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
||||||
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
|
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2028,9 +2028,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
||||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -7516,12 +7516,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.4",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
|
||||||
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
|
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.4",
|
"@next/env": "16.2.6",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -7535,14 +7535,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.4",
|
"@next/swc-darwin-arm64": "16.2.6",
|
||||||
"@next/swc-darwin-x64": "16.2.4",
|
"@next/swc-darwin-x64": "16.2.6",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.4",
|
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.4",
|
"@next/swc-linux-arm64-musl": "16.2.6",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.4",
|
"@next/swc-linux-x64-gnu": "16.2.6",
|
||||||
"@next/swc-linux-x64-musl": "16.2.4",
|
"@next/swc-linux-x64-musl": "16.2.6",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.4",
|
"@next/swc-win32-arm64-msvc": "16.2.6",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.4",
|
"@next/swc-win32-x64-msvc": "16.2.6",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.2",
|
"version": "1.6.4",
|
||||||
"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",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.6",
|
||||||
"next-intl": "^4.9.1",
|
"next-intl": "^4.9.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"pkijs": "^3.4.0",
|
"pkijs": "^3.4.0",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import createIntlMiddleware from "next-intl/middleware";
|
import createIntlMiddleware from "next-intl/middleware";
|
||||||
import { routing } from "./i18n/routing";
|
import { routing } from "./i18n/routing";
|
||||||
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
||||||
|
import { configManager } from "./lib/admin/config-manager";
|
||||||
|
import { detectSetupState } from "./lib/setup/state";
|
||||||
|
|
||||||
const intlMiddleware = createIntlMiddleware(routing);
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
@@ -11,8 +13,62 @@ const intlMiddleware = createIntlMiddleware(routing);
|
|||||||
// requests for API routes, Next internals and static assets.
|
// requests for API routes, Next internals and static assets.
|
||||||
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
|
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
|
||||||
|
|
||||||
|
function isSetupPath(pathname: string): boolean {
|
||||||
|
return (
|
||||||
|
pathname === "/setup" ||
|
||||||
|
pathname.startsWith("/setup/") ||
|
||||||
|
pathname.startsWith("/api/setup")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
|
// Resolve setup state before deciding what to skip. The first call after
|
||||||
|
// boot triggers the config load; subsequent calls are in-memory.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const setupState = detectSetupState();
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
if (setupState === "bootstrap") {
|
||||||
|
// Wizard active. Redirect HTML pages to /setup; let asset/internal
|
||||||
|
// requests through so the wizard UI can render. Block non-setup APIs
|
||||||
|
// with a 503 so cached SPA code doesn't silently call them.
|
||||||
|
const allowed =
|
||||||
|
isSetupPath(pathname) ||
|
||||||
|
pathname === "/api/health" ||
|
||||||
|
pathname.startsWith("/_next/") ||
|
||||||
|
pathname.startsWith("/branding/") ||
|
||||||
|
// Public read endpoint — serves wizard-uploaded branding assets so
|
||||||
|
// image previews work during the wizard. No auth on the GET route.
|
||||||
|
pathname.startsWith("/api/admin/branding/") ||
|
||||||
|
/\.[^/]+$/.test(pathname);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }),
|
||||||
|
{ status: 503, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/setup";
|
||||||
|
url.search = request.nextUrl.search;
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
} else if (isSetupPath(pathname)) {
|
||||||
|
// Configured / env-managed: wizard is no longer reachable.
|
||||||
|
// - HTML /setup pages → redirect to admin login so users who reload
|
||||||
|
// the URL after setup don't see a dead "Not Found" page.
|
||||||
|
// - /api/setup/* → 404 (no reason to expose these endpoints).
|
||||||
|
if (pathname.startsWith("/api/setup")) {
|
||||||
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/admin/login";
|
||||||
|
url.search = "";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PROXY_SKIP_PATTERN.test(pathname)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +106,10 @@ export async function proxy(request: NextRequest) {
|
|||||||
`media-src 'self' blob:`,
|
`media-src 'self' blob:`,
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
// Skip intl middleware for /admin routes - they have their own layout
|
// Skip intl middleware for /admin and /setup routes - they have their
|
||||||
const pathname = request.nextUrl.pathname;
|
// own layout outside the [locale] tree.
|
||||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
||||||
|
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
|
||||||
|
|
||||||
// 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 -
|
||||||
@@ -63,7 +120,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
||||||
if (!isAdminRoute && !hasLocalePrefix) {
|
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
|
||||||
try {
|
try {
|
||||||
intlResponse = intlMiddleware(request);
|
intlResponse = intlMiddleware(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils';
|
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
|
||||||
|
|
||||||
export interface AccountEntry {
|
export interface AccountEntry {
|
||||||
/** Unique key: `${username}@${serverHostname}` */
|
/** Unique key: `${username}@${serverHostname}` */
|
||||||
@@ -13,7 +13,7 @@ export interface AccountEntry {
|
|||||||
username: string;
|
username: string;
|
||||||
/** Authentication mode */
|
/** Authentication mode */
|
||||||
authMode: 'basic' | 'oauth';
|
authMode: 'basic' | 'oauth';
|
||||||
/** Cookie slot index (0–4) for session/token cookies */
|
/** Cookie slot index for session/token cookies (0 ≤ slot < MAX_ACCOUNT_SLOTS) */
|
||||||
cookieSlot: number;
|
cookieSlot: number;
|
||||||
/** Whether "Remember Me" was checked (basic auth only) */
|
/** Whether "Remember Me" was checked (basic auth only) */
|
||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
@@ -80,8 +80,9 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.accounts.length >= MAX_ACCOUNTS) {
|
const max = getMaxAccounts();
|
||||||
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
if (state.accounts.length >= max) {
|
||||||
|
throw new Error(`Maximum of ${max} accounts reached`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieSlot = state.getNextCookieSlot();
|
const cookieSlot = state.getNextCookieSlot();
|
||||||
@@ -178,10 +179,9 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
|
|
||||||
getNextCookieSlot: () => {
|
getNextCookieSlot: () => {
|
||||||
const used = new Set(get().accounts.map((a) => a.cookieSlot));
|
const used = new Set(get().accounts.map((a) => a.cookieSlot));
|
||||||
for (let i = 0; i < MAX_ACCOUNTS; i++) {
|
let i = 0;
|
||||||
if (!used.has(i)) return i;
|
while (used.has(i)) i++;
|
||||||
}
|
return i;
|
||||||
return 0; // fallback, shouldn't happen if max is enforced
|
|
||||||
},
|
},
|
||||||
|
|
||||||
hasAccount: (username, serverUrl) => {
|
hasAccount: (username, serverUrl) => {
|
||||||
|
|||||||
+65
-36
@@ -377,27 +377,30 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
// Resolve account/slot info up front so writes can start immediately.
|
||||||
initializeFeatureStores(client);
|
|
||||||
|
|
||||||
// Register in account store
|
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const accountId = generateAccountId(username, serverUrl);
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
||||||
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
||||||
: accountStore.getNextCookieSlot();
|
: accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
// Snapshot current account if switching away and clear stores so
|
// Snapshot/clear before kicking off any feature-store fetches so they
|
||||||
// the new account starts with a clean email/contact/calendar state.
|
// don't write into stores we're about to wipe.
|
||||||
const prevAccountId = get().activeAccountId;
|
const prevAccountId = get().activeAccountId;
|
||||||
if (prevAccountId && prevAccountId !== accountId) {
|
if (prevAccountId && prevAccountId !== accountId) {
|
||||||
snapshotAccount(prevAccountId);
|
snapshotAccount(prevAccountId);
|
||||||
clearAllStores();
|
clearAllStores();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identities can fly in parallel with everything below. JMAPClient
|
||||||
|
// captures the auth header per-request, so the optional TOTP upgrade
|
||||||
|
// doesn't affect this already-issued request.
|
||||||
|
const identitiesPromise = client.getIdentities();
|
||||||
|
|
||||||
// When TOTP was used, try to upgrade to token-based auth so the
|
// When TOTP was used, try to upgrade to token-based auth so the
|
||||||
// session survives TOTP rotation (basic auth embeds the TOTP in
|
// session survives TOTP rotation (basic auth embeds the TOTP in
|
||||||
// every request, which expires after ~30 seconds).
|
// every request, which expires after ~30 seconds). Must complete
|
||||||
|
// before stalwart-context reads the auth header.
|
||||||
let upgradedToOAuth = false;
|
let upgradedToOAuth = false;
|
||||||
let oauthAccessToken: string | null = null;
|
let oauthAccessToken: string | null = null;
|
||||||
let oauthExpiresIn = 0;
|
let oauthExpiresIn = 0;
|
||||||
@@ -439,6 +442,29 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
|
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
|
||||||
|
|
||||||
|
// Run the remaining independent requests in parallel. The session
|
||||||
|
// write and stalwart-context write are best-effort persistence; the
|
||||||
|
// outer login still succeeds even if they log a warning. Errors are
|
||||||
|
// caught locally so Promise.all doesn't reject on either.
|
||||||
|
const sessionWrite: Promise<unknown> = (rememberMe && !upgradedToOAuth)
|
||||||
|
? apiFetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||||
|
}).then((res) => {
|
||||||
|
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
|
||||||
|
}).catch((err) => debug.error('Failed to store session:', err))
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
const [rawIdentities] = await Promise.all([
|
||||||
|
identitiesPromise,
|
||||||
|
sessionWrite,
|
||||||
|
syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
||||||
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
// Store client in multi-account map
|
// Store client in multi-account map
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
bindClientStatusHandlers(client, set, get, accountId);
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
@@ -468,27 +494,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
lastLoginAt: Date.now(),
|
lastLoginAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store session cookie BEFORE setting isAuthenticated to avoid a race
|
|
||||||
// condition: setting isAuthenticated triggers navigation to the main page,
|
|
||||||
// whose checkAuth() would try to read the cookie before it was stored.
|
|
||||||
if (rememberMe && !upgradedToOAuth) {
|
|
||||||
// For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials
|
|
||||||
try {
|
|
||||||
const res = await apiFetch(`/api/auth/session?slot=${cookieSlot}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
debug.error('Failed to store session: server returned', res.status);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
debug.error('Failed to store session:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -507,6 +512,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
activeAccountId: accountId,
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Kick off mailbox/quota/email fetches now so they overlap with the
|
||||||
|
// soft-nav + home-page hydration that follows login. Dynamic import
|
||||||
|
// avoids a static circular dep with email-store.
|
||||||
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||||
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||||
|
debug.error('Initial data prefetch failed:', err);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// Schedule token refresh for TOTP-upgraded sessions
|
// Schedule token refresh for TOTP-upgraded sessions
|
||||||
if (upgradedToOAuth && oauthExpiresIn > 0) {
|
if (upgradedToOAuth && oauthExpiresIn > 0) {
|
||||||
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
|
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
|
||||||
@@ -704,6 +718,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
activeAccountId: accountId,
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||||
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||||
|
debug.error('Initial data prefetch failed:', err);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
|
|
||||||
notifyParent('sso:auth-success', { username });
|
notifyParent('sso:auth-success', { username });
|
||||||
@@ -750,12 +770,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const slot = accountStore.getNextCookieSlot();
|
const slot = accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
const ssoRes = await apiFetch('/api/auth/sso/complete', {
|
// SSO token exchange and config fetch are independent — fire both
|
||||||
method: 'POST',
|
// up front and let them resolve in parallel.
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const [ssoRes, config] = await Promise.all([
|
||||||
credentials: 'include',
|
apiFetch('/api/auth/sso/complete', {
|
||||||
body: JSON.stringify({ code, state, slot }),
|
method: 'POST',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ code, state, slot }),
|
||||||
|
}),
|
||||||
|
fetchConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!ssoRes.ok) {
|
if (!ssoRes.ok) {
|
||||||
const errorData = await ssoRes.json().catch(() => ({ error: 'token_exchange_failed' }));
|
const errorData = await ssoRes.json().catch(() => ({ error: 'token_exchange_failed' }));
|
||||||
@@ -764,8 +789,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
const { access_token, expires_in } = await ssoRes.json();
|
const { access_token, expires_in } = await ssoRes.json();
|
||||||
|
|
||||||
// We need the server URL from config
|
|
||||||
const config = await fetchConfig();
|
|
||||||
const ssoServerUrl = config.jmapServerUrl;
|
const ssoServerUrl = config.jmapServerUrl;
|
||||||
|
|
||||||
if (!ssoServerUrl) {
|
if (!ssoServerUrl) {
|
||||||
@@ -833,6 +856,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
activeAccountId: accountId,
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||||
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||||
|
debug.error('Initial data prefetch failed:', err);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
|
|
||||||
notifyParent('sso:auth-success', { username });
|
notifyParent('sso:auth-success', { username });
|
||||||
|
|||||||
+34
-3
@@ -72,10 +72,14 @@ interface EmailStore {
|
|||||||
// JMAP operations
|
// JMAP operations
|
||||||
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
|
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
|
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
|
||||||
|
// Eager post-login bootstrap: fires mailboxes/quota/emails so the round-trips
|
||||||
|
// overlap with Next's soft-nav + home-page hydration. Safe to call multiple
|
||||||
|
// times; later calls are no-ops while a prior one is in flight.
|
||||||
|
prefetchInitialData: (client: IJMAPClient) => Promise<void>;
|
||||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[]) => Promise<void>;
|
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], envelopeMailFrom?: string) => Promise<void>;
|
||||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
||||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
@@ -350,6 +354,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
prefetchInitialData: async (client) => {
|
||||||
|
// Coalesce overlapping callers (e.g. login() and a slow home-page useEffect
|
||||||
|
// racing for the same fetch). The promise is stashed on the client so we
|
||||||
|
// don't need a separate keyed map and stale entries can't outlive the client.
|
||||||
|
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void> };
|
||||||
|
if (target.__prefetchPromise) return target.__prefetchPromise;
|
||||||
|
target.__prefetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
get().fetchMailboxes(client),
|
||||||
|
get().fetchQuota(client),
|
||||||
|
]);
|
||||||
|
const { selectedMailbox } = get();
|
||||||
|
if (selectedMailbox) {
|
||||||
|
await get().fetchEmails(client, selectedMailbox);
|
||||||
|
} else {
|
||||||
|
await get().fetchEmails(client);
|
||||||
|
}
|
||||||
|
// Tag counts can finish whenever; don't block the prefetch on them.
|
||||||
|
void get().fetchTagCounts(client);
|
||||||
|
} finally {
|
||||||
|
delete target.__prefetchPromise;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return target.__prefetchPromise;
|
||||||
|
},
|
||||||
|
|
||||||
fetchEmails: async (client, mailboxId) => {
|
fetchEmails: async (client, mailboxId) => {
|
||||||
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
||||||
try {
|
try {
|
||||||
@@ -534,10 +565,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references) => {
|
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references);
|
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom);
|
||||||
// Refresh handled by UI layer for immediate feedback
|
// Refresh handled by UI layer for immediate feedback
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
|||||||
export type ListDensity = Density;
|
export type ListDensity = Density;
|
||||||
export type DeleteAction = 'trash' | 'permanent';
|
export type DeleteAction = 'trash' | 'permanent';
|
||||||
export type ReplyMode = 'reply' | 'replyAll';
|
export type ReplyMode = 'reply' | 'replyAll';
|
||||||
|
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||||
export type TimeFormat = '12h' | '24h';
|
export type TimeFormat = '12h' | '24h';
|
||||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||||
@@ -38,7 +39,7 @@ export type MailAttachmentAction = 'preview' | 'download';
|
|||||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||||
export type ToolbarPosition = 'top' | 'below-subject';
|
export type ToolbarPosition = 'top' | 'below-subject';
|
||||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||||
export type MailLayout = 'split' | 'focus';
|
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||||
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
||||||
|
|
||||||
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
||||||
@@ -143,6 +144,7 @@ interface SettingsState {
|
|||||||
autoSelectReplyIdentity: boolean;
|
autoSelectReplyIdentity: boolean;
|
||||||
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
||||||
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
|
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
|
||||||
|
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
sessionTimeout: number; // minutes (0 = never)
|
sessionTimeout: number; // minutes (0 = never)
|
||||||
@@ -295,6 +297,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
autoSelectReplyIdentity: false,
|
autoSelectReplyIdentity: false,
|
||||||
plainTextMode: false,
|
plainTextMode: false,
|
||||||
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||||
|
signaturePosition: 'below_quote' as SignaturePosition,
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
sessionTimeout: 0, // Never
|
sessionTimeout: 0, // Never
|
||||||
@@ -466,6 +469,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||||
plainTextMode: state.plainTextMode,
|
plainTextMode: state.plainTextMode,
|
||||||
subAddressDelimiter: state.subAddressDelimiter,
|
subAddressDelimiter: state.subAddressDelimiter,
|
||||||
|
signaturePosition: state.signaturePosition,
|
||||||
sessionTimeout: state.sessionTimeout,
|
sessionTimeout: state.sessionTimeout,
|
||||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||||
emailNotificationSound: state.emailNotificationSound,
|
emailNotificationSound: state.emailNotificationSound,
|
||||||
|
|||||||
+25
-6
@@ -11,6 +11,10 @@ const SIDEBAR_DEFAULT = 256;
|
|||||||
const EMAIL_LIST_MIN = 240;
|
const EMAIL_LIST_MIN = 240;
|
||||||
const EMAIL_LIST_MAX = 600;
|
const EMAIL_LIST_MAX = 600;
|
||||||
const EMAIL_LIST_DEFAULT = 384;
|
const EMAIL_LIST_DEFAULT = 384;
|
||||||
|
// Email list height (in pixels) for horizontal "Reading Pane at Bottom" layout
|
||||||
|
const EMAIL_LIST_HEIGHT_MIN = 160;
|
||||||
|
const EMAIL_LIST_HEIGHT_MAX = 800;
|
||||||
|
const EMAIL_LIST_HEIGHT_DEFAULT = 320;
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
// Mobile view state
|
// Mobile view state
|
||||||
@@ -28,6 +32,7 @@ interface UIState {
|
|||||||
// Resizable column widths (desktop only)
|
// Resizable column widths (desktop only)
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
emailListWidth: number;
|
emailListWidth: number;
|
||||||
|
emailListHeight: number;
|
||||||
|
|
||||||
// Sidebar collapsed state (desktop)
|
// Sidebar collapsed state (desktop)
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -40,8 +45,10 @@ interface UIState {
|
|||||||
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
||||||
setSidebarWidth: (width: number) => void;
|
setSidebarWidth: (width: number) => void;
|
||||||
setEmailListWidth: (width: number) => void;
|
setEmailListWidth: (width: number) => void;
|
||||||
|
setEmailListHeight: (height: number) => void;
|
||||||
resetSidebarWidth: () => void;
|
resetSidebarWidth: () => void;
|
||||||
resetEmailListWidth: () => void;
|
resetEmailListWidth: () => void;
|
||||||
|
resetEmailListHeight: () => void;
|
||||||
persistColumnWidths: () => void;
|
persistColumnWidths: () => void;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
toggleSidebarCollapsed: () => void;
|
toggleSidebarCollapsed: () => void;
|
||||||
@@ -64,6 +71,7 @@ export const useUIStore = create<UIState>((set, get) => ({
|
|||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
sidebarWidth: SIDEBAR_DEFAULT,
|
sidebarWidth: SIDEBAR_DEFAULT,
|
||||||
emailListWidth: EMAIL_LIST_DEFAULT,
|
emailListWidth: EMAIL_LIST_DEFAULT,
|
||||||
|
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -84,26 +92,37 @@ export const useUIStore = create<UIState>((set, get) => ({
|
|||||||
setEmailListWidth: (width) =>
|
setEmailListWidth: (width) =>
|
||||||
set({ emailListWidth: Math.min(EMAIL_LIST_MAX, Math.max(EMAIL_LIST_MIN, width)) }),
|
set({ emailListWidth: Math.min(EMAIL_LIST_MAX, Math.max(EMAIL_LIST_MIN, width)) }),
|
||||||
|
|
||||||
|
setEmailListHeight: (height) =>
|
||||||
|
set({ emailListHeight: Math.min(EMAIL_LIST_HEIGHT_MAX, Math.max(EMAIL_LIST_HEIGHT_MIN, height)) }),
|
||||||
|
|
||||||
resetSidebarWidth: () => {
|
resetSidebarWidth: () => {
|
||||||
set({ sidebarWidth: SIDEBAR_DEFAULT });
|
set({ sidebarWidth: SIDEBAR_DEFAULT });
|
||||||
const { emailListWidth } = get();
|
const { emailListWidth, emailListHeight } = get();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth }));
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth, emailListHeight }));
|
||||||
} catch { /* localStorage may be unavailable */ }
|
} catch { /* localStorage may be unavailable */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
resetEmailListWidth: () => {
|
resetEmailListWidth: () => {
|
||||||
set({ emailListWidth: EMAIL_LIST_DEFAULT });
|
set({ emailListWidth: EMAIL_LIST_DEFAULT });
|
||||||
const { sidebarWidth } = get();
|
const { sidebarWidth, emailListHeight } = get();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT }));
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT, emailListHeight }));
|
||||||
|
} catch { /* localStorage may be unavailable */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
resetEmailListHeight: () => {
|
||||||
|
set({ emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT });
|
||||||
|
const { sidebarWidth, emailListWidth } = get();
|
||||||
|
try {
|
||||||
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT }));
|
||||||
} catch { /* localStorage may be unavailable */ }
|
} catch { /* localStorage may be unavailable */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
persistColumnWidths: () => {
|
persistColumnWidths: () => {
|
||||||
const { sidebarWidth, emailListWidth } = get();
|
const { sidebarWidth, emailListWidth, emailListHeight } = get();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth }));
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight }));
|
||||||
} catch { /* localStorage may be unavailable */ }
|
} catch { /* localStorage may be unavailable */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user