Compare commits

...
43 Commits
Author SHA1 Message Date
Linus Rath 869ee07ebc chore: bump next to 16.2.6 for security advisories 2026-05-11 20:11:05 +02:00
Linus Rath 2ad2bb1e09 chore: update version to 1.6.4 2026-05-11 20:00:55 +02:00
Linus Rath 23bc31c661 i18n: add missing translation keys across 15 locales 2026-05-11 19:30:52 +02:00
Linus Rath a2f76037a1 feat: update README and FEATURES.md 2026-05-11 19:24:32 +02:00
Linus Rath 9571f2e185 fix: skip upstream JMAP reverify for trusted URLs #237 2026-05-11 19:22:37 +02:00
Linus Rath 887b9c728c feat: drag attachments out to local file system #267 2026-05-11 17:34:24 +02:00
Linus Rath 8c21f462c2 feat: add signature position to email behavior settings search 2026-05-11 17:07:24 +02:00
Linus Rath 5f3d2d3e4a feat: signature above quoted text option #266 2026-05-11 17:05:59 +02:00
Linus Rath 4bce80b8ba feat: show avatar in Focused list for compact density and above 2026-05-11 16:23:15 +02:00
Linus Rath 1d09f5a623 feat: align Focused list preview with other layout previews 2026-05-11 15:41:25 +02:00
Linus Rath 2c513129f2 feat: add Reading Pane at Bottom mail layout #262 2026-05-11 15:35:43 +02:00
Linus Rath b3dc2e32b8 feat: implement prefetching of initial email data 2026-05-11 15:17:30 +02:00
Augustin MarcinandLinus Rath b0640c9ecc feat(compose): From override + catch-all auto-reply (fixes #246)
Adds an Override toggle in the composer's From row. When enabled, name
and address become free-text inputs. Mail is still submitted through the
selected identity, but the outgoing message's From: header — and the
SMTP envelope MAIL FROM when different — is set from the override.

The existing "Auto-select Reply Address" setting is extended: if the
incoming message was addressed to an alias on a domain that matches one
of your identities but isn't itself an identity (classic domain catch-
all), it now auto-enables Override and pre-fills the alias. Quick reply
honors the same resolution. The setting is relabeled to reflect the
broader behavior.

JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the
EmailSubmission includes an explicit envelope with that mailFrom and the
to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3).

S/MIME: override is incompatible with sign/encrypt and is refused with a
clear error — signing a different visible From from the identity's
certificate Subject would produce messages clients reject.

Tests: resolveReplyFrom covers exact match, sub-address stripping,
catch-all detection, identity preference, and foreign-domain null.
2026-05-11 12:13:47 +02:00
Linus Rath 2d7e24b513 perf: parallelize login round-trips and drop redundant JMAP re-verify 2026-05-11 11:04:13 +02:00
Linus Rath 5b30bacf10 feat: redesign review step with grouped summary and advanced toggle 2026-05-10 01:09:00 +02:00
Linus Rath fe937403f3 style: consistent notice cards for server probe results 2026-05-09 21:43:04 +02:00
Linus Rath 876ea370e4 feat: allow file uploads on the wizard branding step 2026-05-09 21:40:39 +02:00
Linus Rath 1dcdeeae86 style: consistent notice cards for server probe results 2026-05-09 18:12:43 +02:00
Linus Rath 01302a775c feat: require explicit confirmation when JMAP probe finds no session 2026-05-09 17:52:44 +02:00
Linus Rath 76d78ae756 fix: drop redundant first-login banner about removing ADMIN_PASSWORD #222 2026-05-09 17:40:53 +02:00
Linus Rath 51745ea03d feat: web setup wizard + admin config/state dir split (#226) 2026-05-09 17:37:41 +02:00
ChanceandLinus Rath c44a9ce6e0 fix: fall back to primary identity signature on reply
When auto-select picks an alias identity matching the original recipient,
the alias often has no signature configured. The composer was using the
alias's empty signature for both the visual preview and the appended
signature on send, so neither showed up. New mail worked because no
auto-select runs.

Add a signatureIdentity that falls back to the primary when the current
identity has no signature. From address, identity ID, S/MIME, and draft
saves still use currentIdentity so mail goes out from the right address.
2026-05-09 14:21:05 +02:00
Linus Rath 7fa65796f0 fix: show account identity in switcher header instead of sending alias 2026-05-09 13:21:13 +02:00
Linus Rath d09df7e8a3 fix: remove benchmark directory from .gitignore 2026-05-09 13:13:08 +02:00
Linus Rath 090399a308 chore: update version to 1.6.3 2026-05-08 21:10:21 +02:00
Linus Rath c31a58af1a i18n: add missing translation keys across 15 locales 2026-05-08 21:07:14 +02:00
Linus Rath 65aabb943c fix: fall back when only truncation indicator remains in email preview 2026-05-08 20:29:16 +02:00
Linus Rath 9c8739c4bb fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:27:03 +02:00
Linus Rath 48f72be209 fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:26:17 +02:00
Linus Rath 92fb0c63e9 fix: trim leading whitespace from email list preview 2026-05-08 20:19:25 +02:00
Linus Rath 55596556ef feat: import .eml files via folder right-click menu 2026-05-08 20:12:33 +02:00
Linus Rath abd63d124f fix: add benchmark directory to ESLint ignore list 2026-05-08 19:56:21 +02:00
Linus Rath 562080b7a3 fix: request shareWith explicitly so calendar/address book shares survive a re-login #257 2026-05-08 19:53:32 +02:00
Linus Rath 41c9f4926c fix: prevent white-on-white in dark mode for nested bgcolor containers 2026-05-08 02:49:58 +02:00
Linus Rath e7e78072d4 fix: render plain-text-only emails as text, not HTML 2026-05-07 17:49:39 +02:00
Linus Rath cd363b4840 fix: strip leading punctuation when computing avatar initials 2026-05-07 17:43:02 +02:00
Linus Rath 3a350c14a6 fix: hide files/contacts nav items when JMAP server lacks support 2026-05-07 17:21:47 +02:00
Linus Rath b0765bf085 style: redesign quick reply to match sender/banner layout 2026-05-07 17:02:06 +02:00
Linus Rath 9225ba0790 fix: render HTML-only emails and redesign external content prompt 2026-05-07 16:10:26 +02:00
Linus Rath 3b36738192 fix: pad Word/Outlook HTML email rendering 2026-05-07 15:42:47 +02:00
Linus Rath 3edd35ab57 fix: hide email hover actions on mobile 2026-05-07 15:23:05 +02:00
Linus Rath a86a96e390 fix: disable StarterKit's bundled link/underline to avoid duplicate extensions 2026-05-07 12:33:51 +02:00
Linus Rath 5f464d4ee2 feat: lift 5-account cap on HTTP/2 2026-05-07 12:28:33 +02:00
97 changed files with 4986 additions and 555 deletions
+21 -4
View File
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Admin Dashboard Data
# =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash,
# installed plugins/themes, and audit logs (default: ./data/admin).
# For Docker, the default resolves to /app/data/admin - mount a persistent
# volume there (see docker-compose.yml).
# Admin data is split across two directories so the config volume can be
# mounted read-only after the setup wizard completes (see issue #226).
#
# 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
# =============================================================================
+65
View File
@@ -1,5 +1,70 @@
# 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)
### Features
+1 -1
View File
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
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
EXPOSE 3000
ENV PORT=3000
+23 -12
View File
@@ -2,19 +2,23 @@
## 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
- Unified mailbox view across all connected accounts
- Draft auto-save with identity preservation
- Attachment upload, download, and inline preview; forgotten-attachment warning
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- 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
- Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- 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
- Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer
@@ -79,7 +83,7 @@
## 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
- Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation
@@ -100,25 +104,32 @@ Automatic browser detection with persistent preference. Configurable locale URL
## 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
- 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
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
## Admin & Extensibility
- Stalwart admin dashboard with dedicated policy sections
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
- 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
- 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
- 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
## Operations
- Progressive Web App with service worker, install prompt, and dynamic manifest
- Automatic update check with server-side logging of new releases
- 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 and a non-dismissible update notice
- 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
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required
+45 -12
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.6.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](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
<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
- **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)**.
@@ -74,28 +97,25 @@ Full feature list: **[FEATURES.md](FEATURES.md)**.
### Docker
```bash
docker run -d -p 3000:3000 \
-e JMAP_SERVER_URL=https://mail.example.com \
ghcr.io/bulwarkmail/webmail:latest
docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
```
Or with Docker Compose:
```bash
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
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
```bash
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard
```
### Development
@@ -108,13 +128,13 @@ npm run lint
## 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`:
```env
# Required
# Optional overrides whatever the wizard writes
JMAP_SERVER_URL=https://mail.example.com
# Optional
APP_NAME=My Webmail
```
@@ -218,6 +238,19 @@ LOG_LEVEL=info # error | warn | info | debug
</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>
<summary>Subpath / reverse proxy mount</summary>
+1 -1
View File
@@ -1 +1 @@
1.6.2
1.6.3
+126 -48
View File
@@ -53,6 +53,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils";
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 { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
@@ -202,7 +203,7 @@ export default function Home() {
// Mobile/tablet responsive hooks
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 {
emails,
mailboxes,
@@ -636,6 +637,7 @@ export default function Home() {
const parsed = JSON.parse(stored);
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
if (parsed.emailListHeight) setEmailListHeight(parsed.emailListHeight);
}
} catch { /* ignore parse errors */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -649,7 +651,10 @@ export default function Home() {
}
}, [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(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
let retryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -657,18 +662,14 @@ export default function Home() {
const loadData = async (attempt = 1) => {
try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([
fetchMailboxes(client),
fetchQuota(client)
]);
// Get the selected mailbox (should be inbox by default)
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
// On first login the server may still be provisioning mailboxes.
// Retry a few times with back-off before giving up.
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
@@ -676,34 +677,13 @@ export default function Home() {
return;
}
// Fetch emails for the selected mailbox
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} else {
await fetchEmails(client);
}
// Fetch tag counts
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) {
console.error('Error loading email data:', error);
}
@@ -713,17 +693,33 @@ export default function Home() {
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
};
}
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
// Push notifications: set up once per client and tear down when the client
// goes away (logout or account switch). Kept separate from the fetch effect
// above so it still runs when data was prefetched at login time.
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);
}
// Cleanup push notifications on unmount
return () => {
if (client) {
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
// than one account is connected. Runs whenever the set of connected accounts
@@ -875,6 +871,7 @@ export default function Home() {
fromEmail?: string;
fromName?: string;
identityId?: string;
envelopeMailFrom?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
@@ -885,7 +882,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode;
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);
// 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 () => {
if (!client) return;
try {
@@ -1604,9 +1638,28 @@ export default function Home() {
}
const primaryIdentity = identities[0];
const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity;
// Append signature from the primary identity
const finalBody = appendPlainTextSignature(body, primaryIdentity);
// Decide the sending identity and (for domain-catch-all) an optional
// 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;
@@ -1624,14 +1677,15 @@ export default function Home() {
finalBody,
undefined,
undefined,
primaryIdentity?.id,
primaryIdentity?.email,
sendingIdentity?.id,
headerFromEmail,
undefined,
primaryIdentity?.name || undefined,
headerFromName,
undefined,
undefined,
threading?.inReplyTo,
threading?.references,
envelopeMailFrom,
);
// Mark the original email as answered
@@ -1660,9 +1714,11 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => {
@@ -1894,6 +1950,7 @@ export default function Home() {
onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu}
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
setComposerMode('compose');
@@ -1920,21 +1977,30 @@ export default function Home() {
{/* Main Content Area */}
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
<div className="flex flex-1 min-h-0">
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
<div
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
"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",
// Tablet/Desktop: fixed width with collapse animation
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
"md:shadow-sm",
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
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",
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 */}
<MobileHeader
@@ -2243,7 +2309,7 @@ export default function Home() {
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && (
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -2251,17 +2317,29 @@ export default function Home() {
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 */}
<div
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
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative",
shouldHideViewerPane && "md:hidden"
shouldHideViewerPane && "md:hidden",
shouldHideHorizontalViewerPane && "md:hidden"
)}
>
{/* Inline Composer - shown in viewer pane */}
+1
View File
@@ -192,6 +192,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
],
identities: ['settings.identities'],
-12
View File
@@ -119,18 +119,6 @@ export function DashboardTab() {
</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">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
+1 -1
View File
@@ -30,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ 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: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
+3 -3
View File
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<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 ? (
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
) : (
<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" />
)}
</div>
)}
<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>
</div>
+7 -4
View File
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
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> = {
'.svg': 'image/svg+xml',
@@ -38,11 +41,11 @@ export async function GET(
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);
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
+8 -5
View File
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
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 ALLOWED_MIME_TYPES = new Set([
'image/svg+xml',
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(BRANDING_DIR, safeName);
const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists
if (!existsSync(BRANDING_DIR)) {
await mkdir(BRANDING_DIR, { recursive: true });
if (!existsSync(getBrandingDir())) {
await mkdir(getBrandingDir(), { recursive: true });
}
// Write file to disk
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false;
for (const ext of possibleExts) {
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
+18 -6
View File
@@ -4,7 +4,12 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
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 {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
@@ -13,6 +18,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -23,7 +29,7 @@ function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
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;
}
@@ -70,10 +76,16 @@ export async function POST(request: NextRequest) {
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 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 cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
@@ -185,8 +197,8 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) {
// Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) {
// Delete all session cookies across every slot.
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
}
+2 -2
View File
@@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
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_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
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 });
}
+13 -4
View File
@@ -1,14 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
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 { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
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;
}
@@ -16,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
if (raw === null) return 0;
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) {
@@ -56,7 +57,15 @@ export async function POST(request: NextRequest) {
}
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, {
serverUrl: normalizedServerUrl,
+5 -4
View File
@@ -4,12 +4,13 @@ import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
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;
}
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
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 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';
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();
for (let i = 0; i <= 4; i++) {
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
const name = refreshTokenCookieName(i);
const serverCookieName = refreshTokenServerCookieName(i);
const token = cookieStore.get(name)?.value;
+2 -1
View File
@@ -9,6 +9,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
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.
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
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;
// Pin the upstream URL to a configured JMAP server. The list of allowed
+3 -3
View File
@@ -1,8 +1,8 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
/**
* Runtime configuration endpoint
@@ -35,8 +35,8 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
+8 -4
View File
@@ -6,7 +6,8 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
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 } {
const code = (error as NodeJS.ErrnoException).code;
@@ -49,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
}
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. */
@@ -59,7 +63,7 @@ function normalizeUrl(url: string): string {
/**
* 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
* (used by OAuth/SSO and TOTP-upgraded sessions).
* 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 normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) {
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
+167
View File
@@ -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 });
}
}
+107
View File
@@ -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 },
);
}
}
+52
View File
@@ -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' } },
);
}
+118
View File
@@ -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 });
}
}
+104
View File
@@ -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);
}
+49
View File
@@ -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;
}
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+172 -36
View File
@@ -32,7 +32,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
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 { RichTextEditor } from "@/components/email/rich-text-editor";
@@ -55,6 +55,10 @@ export interface ComposerDraftData {
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo'];
draftId: string | null;
/** When set, overrides the header From: — sent through the selected identity's envelope. */
fromOverrideEmail?: string;
fromOverrideName?: string;
fromOverrideEnabled?: boolean;
}
interface EmailComposerProps {
@@ -69,6 +73,7 @@ interface EmailComposerProps {
fromEmail?: string;
fromName?: string;
identityId?: string;
envelopeMailFrom?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
@@ -130,6 +135,24 @@ export function EmailComposer({
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
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
const getInitialTo = () => {
@@ -179,10 +202,18 @@ export function EmailComposer({
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
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') {
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') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
}
return prefix;
}
@@ -194,20 +225,36 @@ export function EmailComposer({
const from = replyTo.from?.[0];
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
return '';
};
const signatureBlock = buildEmbeddedSignatureHtml();
// Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><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) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
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') {
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;
@@ -245,6 +292,9 @@ export function EmailComposer({
const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
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 [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
@@ -276,24 +326,33 @@ export function EmailComposer({
});
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || 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(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, {
const resolved = resolveReplyFrom(identities, {
to: replyTo?.to,
cc: replyTo?.cc,
bcc: replyTo?.bcc,
});
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
if (resolved) {
setSelectedIdentityId(resolved.identityId);
if (resolved.overrideEmail && !fromOverrideEnabled) {
setFromOverrideEnabled(true);
setFromOverrideEmail(resolved.overrideEmail);
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
}
return;
}
@@ -312,6 +371,7 @@ export function EmailComposer({
}
}, [
autoSelectReplyIdentity,
fromOverrideEnabled,
identities,
initialData?.selectedIdentityId,
mode,
@@ -322,10 +382,10 @@ export function EmailComposer({
selectedIdentityId,
]);
const composerSignatureHtml = currentIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
@@ -361,8 +421,8 @@ export function EmailComposer({
}, [currentSmimeIdentityId]);
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
stateRef.current = { 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, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
// Track initial values for dirty detection (captured once on first render)
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
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
const identityFromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
? fromOverrideEmail.trim()
: identityFromEmail;
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
? (fromOverrideName.trim() || undefined)
: (currentIdentity?.name || undefined);
try {
const savedDraftId = await client.createDraft(
@@ -772,7 +838,7 @@ export function EmailComposer({
fromEmail,
draftId || undefined,
uploadedAttachments,
currentIdentity?.name || undefined,
fromName,
plainTextMode ? undefined : body
);
@@ -945,20 +1011,38 @@ export function EmailComposer({
}
}
const fromEmail = currentIdentity?.email
const identityFromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: 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).
// 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)
const buildSignatureHtml = (): string => {
if (currentIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
if (signatureAlreadyInBody) return '';
if (signatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
}
if (currentIdentity?.textSignature) {
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
if (signatureIdentity?.textSignature) {
return `<br><br>-- <br>${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
return '';
};
@@ -970,8 +1054,8 @@ export function EmailComposer({
// In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity)
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity));
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
@@ -1006,6 +1090,12 @@ export function EmailComposer({
if (smimeSign_ && !smimeKeyRecord) {
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
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
@@ -1150,8 +1240,9 @@ export function EmailComposer({
htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
fromName,
identityId: outgoing.identityId || currentIdentity?.id,
envelopeMailFrom,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
@@ -1179,7 +1270,7 @@ export function EmailComposer({
setSubAddressTag("");
setValidationErrors({});
// 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) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
@@ -1190,7 +1281,7 @@ export function EmailComposer({
if (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?.();
};
@@ -1200,7 +1291,7 @@ export function EmailComposer({
clearTimeout(saveTimeoutRef.current);
}
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?.();
};
@@ -1212,7 +1303,7 @@ export function EmailComposer({
if (draftId && onDiscardDraft) {
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?.();
};
@@ -1296,7 +1387,25 @@ export function EmailComposer({
<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>
<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
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
@@ -1328,6 +1437,7 @@ export function EmailComposer({
)}
</span>
)}
{!fromOverrideEnabled && (
<SubAddressHelper
baseEmail={
(selectedIdentityId
@@ -1337,7 +1447,8 @@ export function EmailComposer({
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
onSelectTag={setSubAddressTag}
/>
{subAddressTag && (
)}
{!fromOverrideEnabled && subAddressTag && (
<Button
type="button"
variant="ghost"
@@ -1349,6 +1460,28 @@ export function EmailComposer({
<X className="w-3 h-3" />
</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>
@@ -1499,10 +1632,13 @@ export function EmailComposer({
</div>
)}
{plainTextMode ? (
getPlainTextSignature(currentIdentity) ? (
{/* Hide the visual signature preview when the signature has already been
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">
{'-- \n'}{getPlainTextSignature(currentIdentity)}
{'-- \n'}{getPlainTextSignature(signatureIdentity)}
</div>
) : null
) : composerSignatureHtml ? (
+3
View File
@@ -6,6 +6,7 @@ import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
interface EmailHoverActionsProps {
email: Email;
@@ -76,11 +77,13 @@ export function EmailHoverActions({
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions");
const isMobile = useIsMobile();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (isMobile) return null;
if (hoverActions.length === 0) return null;
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
+8 -7
View File
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
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 isFocusedMailLayout = mailLayout === 'focus';
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
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)' }}
>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
@@ -160,11 +161,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)}
{/* Avatar */}
{!isFocusedMailLayout && density !== 'extra-compact' && (
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
@@ -295,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
{trimmedPreview || "No preview available"}
</p>
)}
</>
+1 -1
View File
@@ -109,7 +109,7 @@ export function EmailList({
const estimateSize = useCallback(() => {
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];
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
+223 -21
View File
@@ -96,6 +96,8 @@ import { usePluginStore } from "@/stores/plugin-store";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
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 {
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 }) {
return (
<div>
@@ -854,6 +898,7 @@ export function EmailViewer({
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -2294,15 +2339,26 @@ export function EmailViewer({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Per RFC 8621 § 4.1.4, when a message has only one alternative the server
// exposes the same part in both htmlBody and textBody. The shared part may
// actually be text/plain (plain-text-only mail) - rendering that as HTML
// collapses newlines and skips linkification, so route by the part's type.
const htmlPart = email.htmlBody[0];
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
useHtmlVersion = false;
} else {
// 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 hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
if (hasTextBody && htmlContent) {
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;
}
}
}
// If we should use HTML version and it exists
if (useHtmlVersion && htmlContent) {
@@ -2678,6 +2734,11 @@ export function EmailViewer({
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// 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 ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
@@ -2692,13 +2753,28 @@ export function EmailViewer({
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
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';
// 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).
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>&nbsp;</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>
<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; }
td, th { word-break: break-word; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [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 thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
{(dragProps) => (
<div
key={attachment.id}
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
thumbUrl
@@ -4313,6 +4460,10 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<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>
)}
</DraggableAttachmentChip>
);
})}
{effectiveAttachments.length > 2 && (
@@ -4376,11 +4529,16 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
{(dragProps) => (
<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"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
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" />
<span className="text-xs text-foreground truncate max-w-[180px]">
@@ -4408,6 +4566,8 @@ export function EmailViewer({
)}
</div>
</div>
)}
</DraggableAttachmentChip>
);
})}
</div>
@@ -4550,11 +4710,24 @@ export function EmailViewer({
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{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">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
External Content
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('external_content_warning')}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
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"
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]"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
@@ -4573,12 +4746,15 @@ export function EmailViewer({
setAllowExternalContent(true);
}
}}
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"
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>
)}
@@ -4645,8 +4821,9 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
{(dragProps) => (
<div
key={attachment.id}
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
thumbUrl
@@ -4655,6 +4832,10 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<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>
)}
</DraggableAttachmentChip>
);
})}
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
@@ -4723,11 +4906,16 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
{(dragProps) => (
<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"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
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" />
<span className="text-sm text-foreground truncate max-w-[220px]">
@@ -4755,6 +4943,8 @@ export function EmailViewer({
)}
</div>
</div>
)}
</DraggableAttachmentChip>
);
})}
</div>
@@ -4774,8 +4964,9 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
{(dragProps) => (
<div
key={attachment.id}
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
thumbUrl
@@ -4784,6 +4975,10 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<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>
)}
</DraggableAttachmentChip>
);
})}
{effectiveAttachments.length > 2 && (
@@ -4846,11 +5043,16 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
{(dragProps) => (
<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"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
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" />
<span className="text-xs text-foreground truncate max-w-[180px]">
@@ -4878,6 +5080,8 @@ export function EmailViewer({
)}
</div>
</div>
)}
</DraggableAttachmentChip>
);
})}
</div>
@@ -4933,18 +5137,17 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
)}>
<div className="p-4">
<div className="flex items-start gap-3">
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
name={currentUserName || "You"}
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
value={quickReplyText}
onChange={(e) => setQuickReplyText(e.target.value)}
@@ -5026,7 +5229,6 @@ export function EmailViewer({
)}
</div>
</div>
</div>
</div>)}
</div>
</div>
+2
View File
@@ -139,6 +139,8 @@ export function RichTextEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
link: false,
underline: false,
}),
Underline,
Link.configure({
@@ -331,8 +331,12 @@ function EmailCard({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// 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 hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
if (hasTextBody && htmlContent) {
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
// 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);
} else {
useHtmlVersion = !!htmlContent;
+15 -13
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
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 isChecked = selectedEmailIds.has(email.id);
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
const tagIds = getEmailColorTags(email.keywords);
@@ -148,8 +149,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
@@ -178,11 +179,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
)}
{!isFocusedMailLayout && density !== 'extra-compact' && (
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
@@ -316,7 +317,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
{trimmedPreview || "No preview available"}
</p>
)}
</>
@@ -366,7 +367,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
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 getAccountById = useAccountStore((state) => state.getAccountById);
@@ -506,8 +508,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
@@ -562,11 +564,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
)}
{!isFocusedMailLayout && density !== 'extra-compact' && (
{density !== 'extra-compact' && (
<Avatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size="md"
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
@@ -722,7 +724,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
{trimmedPreview || "No preview available"}
</p>
)}
</>
+7 -6
View File
@@ -6,7 +6,7 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-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 { useRouter } from "@/i18n/navigation";
@@ -49,7 +49,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const logoutAll = useAuthStore((s) => s.logoutAll);
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
@@ -115,9 +114,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};
// Display name for the active account
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
// Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
const displayName = activeAccount?.displayName || activeAccount?.label || "";
const displayEmail = activeAccount?.email || activeAccount?.username || "";
return (
<>
@@ -220,7 +221,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
</div>
{/* Separator + Add Account */}
{accounts.length < MAX_ACCOUNTS && (
{accounts.length < getMaxAccounts() && (
<div className="border-t border-border">
<button
onClick={handleAddAccount}
@@ -20,6 +20,7 @@ import {
Pencil,
FolderX,
RefreshCw,
Upload,
} from "lucide-react";
interface Position {
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefresh?: () => void;
}
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onImportEmail,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const fullPath = getMailboxPath(mailbox, mailboxes);
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
<ContextMenuSeparator />
<ContextMenuItem
icon={Upload}
label={t("import_email")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
+7 -6
View File
@@ -11,14 +11,13 @@ import { usePathname, Link, useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
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 { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -168,7 +167,9 @@ export function NavigationRail({
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore();
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 showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
@@ -252,8 +253,8 @@ export function NavigationRail({
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
@@ -634,7 +635,7 @@ export function NavigationRail({
</button>
);
})}
{accounts.length < MAX_ACCOUNTS && (
{accounts.length < getMaxAccounts() && (
<button
onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
+20 -11
View File
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
onResize: (delta: number) => void;
onResizeEnd?: () => void;
onDoubleClick?: () => void;
orientation?: "vertical" | "horizontal";
className?: string;
}
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 startX = useRef(0);
const startPos = useRef(0);
const isHorizontal = orientation === "horizontal";
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
document.body.style.cursor = "col-resize";
startPos.current = isHorizontal ? e.clientY : e.clientX;
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
onResizeStart?.();
}, [onResizeStart]);
}, [onResizeStart, isHorizontal]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
if (isHorizontal) {
if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
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();
onResize(delta);
onResizeEnd?.();
}, [onResize, onResizeEnd]);
}, [onResize, onResizeEnd, isHorizontal]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const delta = e.clientX - startX.current;
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
onResize(delta);
};
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [onResize, onResizeEnd]);
}, [onResize, onResizeEnd, isHorizontal]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize"
tabIndex={0}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
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",
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
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>
);
}
+3
View File
@@ -66,6 +66,7 @@ interface SidebarProps {
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
className?: string;
}
@@ -636,6 +637,7 @@ export function Sidebar({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onImportEmail,
onRefreshMailboxes,
className,
}: SidebarProps) {
@@ -1037,6 +1039,7 @@ export function Sidebar({
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onImportEmail={onImportEmail}
onRefresh={onRefreshMailboxes}
/>
</div>
@@ -27,6 +27,7 @@ export function ComposingSettings() {
attachmentReminderEnabled,
attachmentReminderKeywords,
subAddressDelimiter,
signaturePosition,
updateSetting,
} = useSettingsStore();
@@ -50,6 +51,17 @@ export function ComposingSettings() {
/>
</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
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
+40 -9
View File
@@ -13,6 +13,12 @@ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ 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({
value,
t,
@@ -20,8 +26,6 @@ function MailLayoutPreview({
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
@@ -33,7 +37,7 @@ function MailLayoutPreview({
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
{value === 'split' && (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
@@ -56,15 +60,16 @@ function MailLayoutPreview({
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
)}
{value === 'focus' && (
<div className="flex-1 bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS_FOCUS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate text-foreground">
@@ -74,6 +79,31 @@ function MailLayoutPreview({
</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) => (
<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>
<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>
@@ -100,6 +130,7 @@ export function LayoutSettings() {
options={[
{ value: 'split', label: tEmail('mail_layout.split') },
{ value: 'focus', label: tEmail('mail_layout.focus') },
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
]}
/>
<MailLayoutPreview value={mailLayout} t={tEmail} />
+8 -1
View File
@@ -189,10 +189,17 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const getInitials = () => {
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) {
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();
}
if (email) {
+7 -1
View File
@@ -11,8 +11,13 @@ services:
volumes:
# Encrypted user settings (SETTINGS_DATA_DIR).
- 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
# 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).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry
@@ -35,4 +40,5 @@ services:
volumes:
bulwark-settings:
bulwark-admin:
bulwark-admin-state:
bulwark-telemetry:
+1
View File
@@ -77,6 +77,7 @@ export default [
"*.config.mjs",
"e2e/**",
"local-data/**/*.mjs",
"benchmark/**",
],
},
];
+122
View File
@@ -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
View File
@@ -1,6 +1,9 @@
import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
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(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
// Initialize admin config and password bootstrap
configManager.load()
// Initialize admin config and password bootstrap. Migration runs first so
// existing v1 layouts are split before anything reads admin.json.
migrateLegacyAdminLayout()
.then(() => configManager.load())
.then(() => initAdminPassword())
.then(() => {
.then(async () => {
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 () => {
// Anonymous telemetry - on by default. Admins can disable via the
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity';
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -50,3 +50,38 @@ describe('findReplyIdentityId', () => {
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
View File
@@ -62,5 +62,43 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
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
View File
@@ -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 path from 'node:path';
import { logger } from '@/lib/logger';
import { ensureStateDir, getStatePath } from './paths';
import type { AuditEntry } from './types';
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_ROTATIONS = 3;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
const AUDIT_LOG_FILE = 'audit.log';
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> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await ensureStateDir();
const entry: AuditEntry = {
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 }> {
const logPath = getAuditLogPath();
try {
const { readFile } = await import('node:fs/promises');
const content = await readFile(logPath, 'utf-8');
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;
// Return newest first
entries.reverse();
const start = (page - 1) * limit;
return { entries: entries.slice(start, start + limit), total };
+36 -14
View File
@@ -1,13 +1,8 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
@@ -127,6 +122,7 @@ class ConfigManager {
* Update admin config overrides. Writes to disk.
*/
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
assertWritable('update admin config');
Object.assign(this.adminConfig, updates);
await this.writeJsonFile('config.json', this.adminConfig);
}
@@ -135,10 +131,29 @@ class ConfigManager {
* Remove an admin override, reverting to env/default.
*/
async removeAdminOverride(key: string): Promise<void> {
assertWritable('remove admin override');
delete this.adminConfig[key];
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.
*/
@@ -150,6 +165,7 @@ class ConfigManager {
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
assertWritable('update settings policy');
this.policyCache = {
...DEFAULT_POLICY,
...policy,
@@ -167,7 +183,7 @@ class ConfigManager {
}
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
const filePath = path.join(getAdminDir(), filename);
const filePath = getConfigPath(filename);
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
@@ -179,15 +195,21 @@ class ConfigManager {
}
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = path.join(dir, filename);
await ensureConfigDir();
const targetPath = getConfigPath(filename);
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
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());
+196
View File
@@ -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 };
}
+113 -82
View File
@@ -1,9 +1,14 @@
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { readFile, writeFile, rename } from 'node:fs/promises';
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_COST = 16384; // 2^14
@@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELIZATION = 1;
const SALT_LENGTH = 32;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getAdminJsonPath(): string {
return path.join(getAdminDir(), 'admin.json');
}
const ADMIN_CONFIG_FILE = 'admin.json';
const ADMIN_STATE_FILE = 'admin-state.json';
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise<string> {
function verifyPassword(password: string, stored: string): Promise<boolean> {
return new Promise((resolve, reject) => {
// Support both scrypt format and bcrypt-prefixed values
if (stored.startsWith('$scrypt$')) {
const parts = stored.split('$');
// $scrypt$N=...,r=...,p=...$salt$hash
if (parts.length !== 5) return resolve(false);
const paramStr = parts[2];
const salt = Buffer.from(parts[3], 'base64');
@@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
resolve(timingSafeEqual(derivedKey, storedHash));
});
} else {
// Unknown format
resolve(false);
}
});
@@ -63,50 +60,84 @@ function isHashed(value: string): boolean {
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
}
async function readAdminData(): Promise<AdminData | null> {
const filePath = getAdminJsonPath();
// ─── Disk I/O ───────────────────────────────────────────────────────────────
async function readJson<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as AdminData;
return JSON.parse(raw) as T;
} catch (error) {
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;
}
}
async function writeAdminData(data: AdminData): Promise<void> {
const dir = getAdminDir();
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);
async function readConfigData(): Promise<AdminConfigData | null> {
return readJson<AdminConfigData>(getConfigPath(ADMIN_CONFIG_FILE));
}
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;
function freshState(): AdminStateData {
const now = new Date().toISOString();
return { createdAt: now, lastLogin: null, passwordChangedAt: now };
}
/**
* Initialize admin password on startup.
* If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
* Returns true if admin is enabled.
* - If admin.json exists, use it (state file may or may not exist; created on first need).
* - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it.
* - Otherwise, admin dashboard stays disabled.
*/
export async function initAdminPassword(): Promise<boolean> {
if (initialized) return cachedAdminData !== null;
if (initialized) return cachedConfig !== null;
// Check persistent file first
const existing = await readAdminData();
if (existing) {
cachedAdminData = existing;
const existingConfig = await readConfigData();
if (existingConfig) {
cachedConfig = existingConfig;
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;
logger.info('Admin dashboard enabled (password loaded from admin.json)');
return true;
}
// Check env var
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
@@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise<boolean> {
return false;
}
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
if (isHashed(envPassword)) {
// Already hashed in env - save to file
const data: AdminData = {
passwordHash: envPassword,
createdAt: new Date().toISOString(),
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;
} else {
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
}
return true;
}
@@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise<boolean> {
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
if (!cachedAdminData) {
cachedAdminData = await readAdminData();
}
if (!cachedAdminData) return false;
return verifyPassword(password, cachedAdminData.passwordHash);
if (!cachedConfig) cachedConfig = await readConfigData();
if (!cachedConfig) return false;
return verifyPassword(password, cachedConfig.passwordHash);
}
/**
@@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
if (!valid) return false;
const hash = await hashPassword(newPassword);
if (!cachedAdminData) return false;
cachedConfig = { passwordHash: hash };
await writeConfigData(cachedConfig);
cachedAdminData = {
...cachedAdminData,
passwordHash: hash,
cachedState = {
...(cachedState ?? freshState()),
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;
}
@@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
* Update the last login timestamp.
*/
export async function updateLastLogin(): Promise<void> {
if (!cachedAdminData) return;
cachedAdminData = {
...cachedAdminData,
if (!cachedConfig) return;
cachedState = {
...(cachedState ?? freshState()),
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).
*/
export function isAdminEnabled(): boolean {
return cachedAdminData !== null;
return cachedConfig !== null;
}
/**
* Get admin metadata (without the hash).
*/
export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
if (!cachedAdminData) return null;
return {
createdAt: cachedAdminData.createdAt,
lastLogin: cachedAdminData.lastLogin,
passwordChangedAt: cachedAdminData.passwordChangedAt,
};
export function getAdminMeta(): AdminStateData | null {
if (!cachedConfig) return null;
return cachedState ?? freshState();
}
+126
View File
@@ -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);
}
+5 -5
View File
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { getConfigDir, assertWritable } from './paths';
function getPluginConfigDir(): string {
return path.join(getAdminDir(), 'plugin-config');
return path.join(getConfigDir(), 'plugin-config');
}
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.
*/
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
assertWritable('update plugin config');
const dir = getPluginConfigDir();
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.
*/
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
assertWritable('delete plugin config key');
const config = await getPluginConfig(pluginId);
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).
*/
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
assertWritable('delete plugin config');
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
+9 -6
View File
@@ -3,17 +3,14 @@ import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { getConfigDir, assertWritable } from './paths';
function getPluginsDir(): string {
return path.join(getAdminDir(), 'plugins');
return path.join(getConfigDir(), 'plugins');
}
function getThemesDir(): string {
return path.join(getAdminDir(), 'themes');
return path.join(getConfigDir(), 'themes');
}
// ─── Types ───────────────────────────────────────────────────
@@ -141,6 +138,7 @@ export async function savePlugin(
plugin: ServerPlugin,
code: string,
): Promise<void> {
assertWritable('install plugin');
const dir = getPluginsDir();
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> {
assertWritable('update plugin metadata');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
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> {
assertWritable('delete plugin');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
@@ -221,6 +221,7 @@ export async function saveTheme(
theme: ServerTheme,
css: string,
): Promise<void> {
assertWritable('install theme');
const dir = getThemesDir();
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> {
assertWritable('update theme metadata');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
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> {
assertWritable('delete theme');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
+2 -2
View File
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
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 type { AdminSessionPayload } from './types';
@@ -12,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
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.length < MIN_SECRET_LENGTH) {
throw new Error(
+19 -1
View File
@@ -1,12 +1,30 @@
// 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;
}
/**
* 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;
lastLogin: string | null;
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 {
role: 'admin';
iat: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
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 IV_LENGTH = 12;
@@ -9,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
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.length < MIN_SECRET_LENGTH) {
throw new Error(
+1 -1
View File
@@ -1,7 +1,7 @@
export const SESSION_COOKIE = 'jmap_session';
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 {
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
}
+31
View File
@@ -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;
}
+1
View File
@@ -146,6 +146,7 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
envelopeMailFrom?: string,
): Promise<void>;
sendImipReply(opts: {
+59 -8
View File
@@ -95,6 +95,41 @@ const EMAIL_LIST_PROPERTIES = [
"hasAttachment",
] 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
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
@@ -2054,7 +2089,8 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[]
references?: string[],
envelopeMailFrom?: string
): Promise<void> {
const emailId = `send-${Date.now()}`;
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) {
// Destroy the old draft and create a new email with the final body
methodCalls.push(["Email/set", {
@@ -2162,7 +2213,7 @@ export class JMAPClient implements IJMAPClient {
}, "1"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
create: submissionCreate("1"),
onSuccessUpdateEmail,
}, "2"]);
} else {
@@ -2172,7 +2223,7 @@ export class JMAPClient implements IJMAPClient {
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
create: submissionCreate("1"),
onSuccessUpdateEmail,
}, "1"]);
}
@@ -3143,7 +3194,7 @@ export class JMAPClient implements IJMAPClient {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/get", { accountId }, "0"]
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
@@ -3168,7 +3219,7 @@ export class JMAPClient implements IJMAPClient {
try {
const response = await this.request([
["AddressBook/get", { accountId }, "0"]
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
@@ -3612,7 +3663,7 @@ export class JMAPClient implements IJMAPClient {
try {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/get", { accountId }, "0"]
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
@@ -3637,7 +3688,7 @@ export class JMAPClient implements IJMAPClient {
try {
const response = await this.request([
["Calendar/get", { accountId }, "0"]
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
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
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
const fetchResponse = await this.request([
["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"]
["Calendar/get", { accountId: fetchAccountId, ids: [createdId], properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
const list = fetchResponse.methodResponses[0][1].list || [];
+1 -1
View File
@@ -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_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 {
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
}
+95
View File
@@ -2,6 +2,7 @@ import type { Identity } from '@/lib/jmap/types';
interface ReplyRecipient {
email?: string | null;
name?: string | null;
}
interface ReplyRecipients {
@@ -29,6 +30,11 @@ function normalizeBaseEmailAddress(email: string): string {
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(
identities: Identity[],
recipients?: ReplyRecipients,
@@ -60,3 +66,92 @@ export function findReplyIdentityId(
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;
}
+2 -2
View File
@@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
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 IV_LENGTH = 12;
const TAG_LENGTH = 16;
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');
return createHash('sha256').update(secret).digest();
}
+33
View File
@@ -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,
};
}
+53
View File
@@ -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'));
}
+111
View File
@@ -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 };
}
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
export interface StalwartCredentials {
/** 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 {
if (raw === null) return null;
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[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-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> {
+15
View File
@@ -78,6 +78,21 @@ export function formatDateTime(
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 {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "...";
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Komu: {recipients}"
},
"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",
"save_as_template": "Uložit jako šablonu",
"validation": {
@@ -941,7 +950,9 @@
"split": "Rozdělené zobrazení",
"split_description": "Seznam zpráv a panel pro čtení zůstávají viditelné vedle sebe.",
"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": {
"label": "Zobrazit náhledový text",
@@ -960,6 +971,12 @@
"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"
},
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nová podsložka...",
"new_folder": "Nová složka...",
"rename": "Přejmenovat...",
"import_email": "Importovat .eml...",
"empty_folder": "Vyprázdnit složku",
"empty_folder_generic": "Vyprázdnit složku",
"delete_folder": "Smazat složku",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "An: {recipients}"
},
"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",
"save_as_template": "Als Vorlage speichern",
"validation": {
@@ -941,7 +950,9 @@
"split": "Geteilter Bereich",
"split_description": "Nachrichtenliste und Lesebereich nebeneinander sichtbar halten.",
"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": {
"label": "Vorschautext anzeigen",
@@ -960,6 +971,12 @@
"label": "Antwortadresse automatisch wählen",
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...",
"rename": "Umbenennen...",
"import_email": ".eml importieren...",
"empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen",
+22 -4
View File
@@ -531,6 +531,15 @@
"to": "To: {recipients}"
},
"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",
"save_as_template": "Save as Template",
"validation": {
@@ -940,11 +949,13 @@
},
"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_description": "Keep the message list and reading pane visible side by side.",
"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": {
"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"
},
"auto_select_reply_identity": {
"label": "Auto-select Reply Address",
"description": "When replying, automatically switch the From address to the identity that originally received the message"
"label": "Reply From Received Address",
"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": {
"label": "Sub-Address Delimiter",
@@ -1684,6 +1701,7 @@
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"import_email": "Import .eml...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Para: {recipients}"
},
"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",
"save_as_template": "Guardar como plantilla",
"validation": {
@@ -941,7 +950,9 @@
"split": "Panel dividido",
"split_description": "Mantener la lista de mensajes y el panel de lectura visibles uno al lado del otro.",
"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": {
"label": "Desactivar agrupación de conversaciones",
@@ -955,6 +966,12 @@
"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"
},
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nueva subcarpeta...",
"new_folder": "Nueva carpeta...",
"rename": "Renombrar...",
"import_email": "Importar .eml...",
"empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "À : {recipients}"
},
"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",
"save_as_template": "Enregistrer comme modèle",
"validation": {
@@ -941,7 +950,9 @@
"split": "Volet divisé",
"split_description": "Garder la liste des messages et le volet de lecture visibles côte à côte.",
"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": {
"label": "Désactiver le regroupement par conversation",
@@ -955,6 +966,12 @@
"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"
},
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nouveau sous-dossier...",
"new_folder": "Nouveau dossier...",
"rename": "Renommer...",
"import_email": "Importer un .eml...",
"empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "A: {recipients}"
},
"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",
"save_as_template": "Salva come modello",
"validation": {
@@ -941,7 +950,9 @@
"split": "Pannello diviso",
"split_description": "Mantieni la lista dei messaggi e il pannello di lettura visibili affiancati.",
"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": {
"label": "Disabilita raggruppamento conversazioni",
@@ -955,6 +966,12 @@
"label": "Seleziona automaticamente l'indirizzo di risposta",
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nuova sottocartella...",
"new_folder": "Nuova cartella...",
"rename": "Rinomina...",
"import_email": "Importa .eml...",
"empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "宛先: {recipients}"
},
"remove_sub_address": "サブアドレスを削除",
"from_override": {
"toggle_off": "上書き",
"toggle_on": "上書きを取り消す",
"toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます — 表示される差出人ヘッダーのみが変更されます。",
"name_label": "差出人名",
"name_placeholder": "名前",
"email_label": "差出人メールアドレス",
"email_placeholder": "alias@example.com"
},
"use_template": "テンプレート",
"save_as_template": "テンプレートとして保存",
"validation": {
@@ -941,7 +950,9 @@
"split": "分割ペイン",
"split_description": "メッセージリストと読み取りペインを並べて表示します。",
"focus": "集中リスト",
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。"
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。",
"horizontal": "閲覧ペインを下に表示",
"horizontal_description": "メッセージ一覧を上に表示し、選択したメッセージを下の閲覧ペインで開きます。"
},
"disable_threading": {
"label": "会話グループ化を無効にする",
@@ -955,6 +966,12 @@
"label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
},
"signature_position": {
"label": "署名の位置",
"description": "返信や転送で署名を挿入する位置。引用テキストの上は返信の締めとして自然に読めます。下は元のメッセージを続けて表示します。",
"above_quote": "引用テキストの前",
"below_quote": "引用テキストの後"
},
"sub_address_delimiter": {
"label": "サブアドレス区切り文字",
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
@@ -1684,6 +1701,7 @@
"new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...",
"rename": "名前を変更...",
"import_email": ".eml をインポート...",
"empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "받는 사람: {recipients}"
},
"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": "템플릿",
"save_as_template": "템플릿으로 저장",
"validation": {
@@ -941,7 +950,9 @@
"split": "화면 분할",
"split_description": "메일 목록과 읽기 창을 나란히 보여줘요.",
"focus": "집중형 목록",
"focus_description": "메일을 한 줄로 보여주고, 클릭하면 폴더 사이드바는 남겨둔 채 메일 내용을 넓게 보여줘요."
"focus_description": "메일을 한 줄로 보여주고, 클릭하면 폴더 사이드바는 남겨둔 채 메일 내용을 넓게 보여줘요.",
"horizontal": "읽기 창 하단 표시",
"horizontal_description": "메시지 목록을 상단에 표시하고 선택한 메시지를 하단 읽기 창에서 엽니다."
},
"show_preview": {
"label": "미리보기 텍스트 표시",
@@ -960,6 +971,12 @@
"label": "답장 시 보내는 사람 자동 선택",
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요"
},
"signature_position": {
"label": "서명 위치",
"description": "답장과 전달에서 서명을 삽입할 위치. 인용된 텍스트 위에 두면 답장의 마무리처럼 자연스럽게 읽히고, 아래에 두면 원본 메시지가 이어져 보입니다.",
"above_quote": "인용 텍스트 앞",
"below_quote": "인용 텍스트 뒤"
},
"sub_address_delimiter": {
"label": "서브 주소 구분자",
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...",
"rename": "이름 바꾸기...",
"import_email": ".eml 가져오기...",
"empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Kam: {recipients}"
},
"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",
"save_as_template": "Saglabāt kā veidni",
"validation": {
@@ -941,7 +950,9 @@
"split": "Dalīts skats",
"split_description": "Ziņojumu saraksts un lasīšanas rūts ir redzamas blakus.",
"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": {
"label": "Izslēgt sarunu grupēšanu",
@@ -955,6 +966,12 @@
"label": "Automātiski izvēlēties atbildes adresi",
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Jauna apakšmape...",
"new_folder": "Jauna mape...",
"rename": "Pārsaukt...",
"import_email": "Importēt .eml...",
"empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Aan: {recipients}"
},
"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",
"save_as_template": "Opslaan als sjabloon",
"validation": {
@@ -941,7 +950,9 @@
"split": "Gesplitst venster",
"split_description": "Houd de berichtenlijst en het leesvenster naast elkaar zichtbaar.",
"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": {
"label": "Conversatiegroepering uitschakelen",
@@ -955,6 +966,12 @@
"label": "Antwoordadres automatisch selecteren",
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nieuwe submap...",
"new_folder": "Nieuwe map...",
"rename": "Hernoemen...",
"import_email": ".eml importeren...",
"empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Do: {recipients}"
},
"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",
"save_as_template": "Zapisz jako szablon",
"validation": {
@@ -941,7 +950,9 @@
"split": "Widok podzielony",
"split_description": "Lista wiadomości i panel czytania pozostają widoczne obok siebie.",
"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": {
"label": "Pokaż tekst podglądu",
@@ -960,6 +971,12 @@
"label": "Automatycznie wybieraj adres odpowiedzi",
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nowy podfolder...",
"new_folder": "Nowy folder...",
"rename": "Zmień nazwę...",
"import_email": "Importuj .eml...",
"empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Para: {recipients}"
},
"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",
"save_as_template": "Salvar como modelo",
"validation": {
@@ -941,7 +950,9 @@
"split": "Painel dividido",
"split_description": "Manter a lista de mensagens e o painel de leitura visíveis lado a lado.",
"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": {
"label": "Desativar agrupamento de conversas",
@@ -955,6 +966,12 @@
"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"
},
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Nova subpasta...",
"new_folder": "Nova pasta...",
"rename": "Renomear...",
"import_email": "Importar .eml...",
"empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Кому: {recipients}"
},
"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": "Шаблон",
"save_as_template": "Сохранить как шаблон",
"validation": {
@@ -941,7 +950,9 @@
"split": "Разделённая панель",
"split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.",
"focus": "Сфокусированный список",
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок."
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок.",
"horizontal": "Область чтения снизу",
"horizontal_description": "Показывать список сообщений сверху и открывать выбранное сообщение в области чтения под ним."
},
"disable_threading": {
"label": "Отключить группировку по беседам",
@@ -955,6 +966,12 @@
"label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
},
"signature_position": {
"label": "Положение подписи",
"description": "Куда вставлять подпись в ответах и пересылке. Над цитируемым текстом она читается естественно как завершение ответа; под ним сохраняет целостность исходного сообщения.",
"above_quote": "Перед цитируемым текстом",
"below_quote": "После цитируемого текста"
},
"sub_address_delimiter": {
"label": "Разделитель суб-адресов",
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...",
"rename": "Переименовать...",
"import_email": "Импортировать .eml...",
"empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку",
+19 -1
View File
@@ -531,6 +531,15 @@
"to": "Kime: {recipients}"
},
"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",
"save_as_template": "Şablon Olarak Kaydet",
"validation": {
@@ -941,7 +950,9 @@
"split": "Bölünmüş bölme",
"split_description": "İleti listesi ve okuma bölmesini yan yana görünür tutun.",
"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": {
"label": "Önizleme Metnini Göster",
@@ -960,6 +971,12 @@
"label": "Yanıt Adresini Otomatik Seç",
"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": {
"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).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Yeni alt klasör...",
"new_folder": "Yeni klasör...",
"rename": "Yeniden adlandır...",
"import_email": ".eml içe aktar...",
"empty_folder": "Klasörü boşalt",
"empty_folder_generic": "Klasörü boşalt",
"delete_folder": "Klasörü sil",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "Кому: {recipients}"
},
"remove_sub_address": "Видалити підадресу",
"from_override": {
"toggle_off": "Замінити",
"toggle_on": "Скасувати заміну",
"toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність — змінюється лише видимий заголовок Від.",
"name_label": "Ім'я відправника",
"name_placeholder": "Ім'я",
"email_label": "Електронна адреса відправника",
"email_placeholder": "alias@example.com"
},
"use_template": "Шаблон",
"save_as_template": "Зберегти як шаблон",
"validation": {
@@ -941,7 +950,9 @@
"split": "Розділена панель",
"split_description": "Тримайте список повідомлень і панель читання видимими поруч.",
"focus": "Сфокусований список",
"focus_description": "Показувати один рядок на повідомлення та відкривати пошту на всю ширину, зберігаючи бічну панель папки видимою."
"focus_description": "Показувати один рядок на повідомлення та відкривати пошту на всю ширину, зберігаючи бічну панель папки видимою.",
"horizontal": "Область читання знизу",
"horizontal_description": "Показуйте список повідомлень угорі та відкривайте вибране повідомлення в області читання під ним."
},
"show_preview": {
"label": "Показати попередній перегляд тексту",
@@ -960,6 +971,12 @@
"label": "Автоматичний вибір адреси для відповіді",
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення"
},
"signature_position": {
"label": "Розташування підпису",
"description": "Куди вставляти підпис у відповідях і пересиланнях. Над цитованим текстом читається природно як завершення відповіді; під ним зберігає цілісність оригінального повідомлення.",
"above_quote": "Перед цитованим текстом",
"below_quote": "Після цитованого тексту"
},
"sub_address_delimiter": {
"label": "Розділювач під-адреси",
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
@@ -1684,6 +1701,7 @@
"new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...",
"rename": "Перейменувати...",
"import_email": "Імпортувати .eml...",
"empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку",
+19 -1
View File
@@ -528,6 +528,15 @@
"to": "至:{recipients}"
},
"remove_sub_address": "删除子地址",
"from_override": {
"toggle_off": "覆盖",
"toggle_on": "取消覆盖",
"toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 — 仅可见的发件人标题发生变化。",
"name_label": "发件人姓名",
"name_placeholder": "姓名",
"email_label": "发件人电子邮件地址",
"email_placeholder": "alias@example.com"
},
"use_template": "模板",
"save_as_template": "保存为模板",
"validation": {
@@ -941,7 +950,9 @@
"split": "分栏视图",
"split_description": "左侧显示邮件列表,右侧显示阅读窗格。",
"focus": "沉浸模式",
"focus_description": "每封邮件显示一行,打开邮件时使用全宽阅读视图,同时保留文件夹侧边栏。"
"focus_description": "每封邮件显示一行,打开邮件时使用全宽阅读视图,同时保留文件夹侧边栏。",
"horizontal": "底部阅读窗格",
"horizontal_description": "在顶部显示邮件列表,并在下方的阅读窗格中打开所选邮件。"
},
"show_preview": {
"label": "显示预览文本",
@@ -960,6 +971,12 @@
"label": "自动选择回复地址",
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份"
},
"signature_position": {
"label": "签名位置",
"description": "在回复和转发中插入签名的位置。位于引用文本上方时,可作为回复的自然结尾;位于下方时,保持原始邮件连贯。",
"above_quote": "引用文本之前",
"below_quote": "引用文本之后"
},
"sub_address_delimiter": {
"label": "子地址分隔符",
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
@@ -1684,6 +1701,7 @@
"new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...",
"rename": "重命名...",
"import_email": "导入 .eml...",
"empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹",
+42 -42
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.6.2",
"version": "1.6.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.6.2",
"version": "1.6.4",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
@@ -30,7 +30,7 @@
"dompurify": "^3.4.1",
"jszip": "^3.10.1",
"lucide-react": "^1.8.0",
"next": "^16.2.4",
"next": "^16.2.6",
"next-intl": "^4.9.1",
"otpauth": "^9.5.0",
"pkijs": "^3.4.0",
@@ -1910,15 +1910,15 @@
}
},
"node_modules/@next/env": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"cpu": [
"arm64"
],
@@ -1932,9 +1932,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"cpu": [
"x64"
],
@@ -1948,9 +1948,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"cpu": [
"arm64"
],
@@ -1964,9 +1964,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"cpu": [
"arm64"
],
@@ -1980,9 +1980,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"cpu": [
"x64"
],
@@ -1996,9 +1996,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"cpu": [
"x64"
],
@@ -2012,9 +2012,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"cpu": [
"arm64"
],
@@ -2028,9 +2028,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"cpu": [
"x64"
],
@@ -7516,12 +7516,12 @@
}
},
"node_modules/next": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.4",
"@next/env": "16.2.6",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -7535,14 +7535,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.4",
"@next/swc-darwin-x64": "16.2.4",
"@next/swc-linux-arm64-gnu": "16.2.4",
"@next/swc-linux-arm64-musl": "16.2.4",
"@next/swc-linux-x64-gnu": "16.2.4",
"@next/swc-linux-x64-musl": "16.2.4",
"@next/swc-win32-arm64-msvc": "16.2.4",
"@next/swc-win32-x64-msvc": "16.2.4",
"@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.6",
"sharp": "^0.34.5"
},
"peerDependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.6.2",
"version": "1.6.4",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
@@ -53,7 +53,7 @@
"dompurify": "^3.4.1",
"jszip": "^3.10.1",
"lucide-react": "^1.8.0",
"next": "^16.2.4",
"next": "^16.2.6",
"next-intl": "^4.9.1",
"otpauth": "^9.5.0",
"pkijs": "^3.4.0",
+61 -4
View File
@@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
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);
@@ -11,8 +13,62 @@ const intlMiddleware = createIntlMiddleware(routing);
// requests for API routes, Next internals and static assets.
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) {
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();
}
@@ -50,9 +106,10 @@ export async function proxy(request: NextRequest) {
`media-src 'self' blob:`,
].join("; ");
// Skip intl middleware for /admin routes - they have their own layout
const pathname = request.nextUrl.pathname;
// Skip intl middleware for /admin and /setup routes - they have their
// own layout outside the [locale] tree.
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
// (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;
if (!isAdminRoute && !hasLocalePrefix) {
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
try {
intlResponse = intlMiddleware(request);
} catch (error) {
+8 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
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 {
/** Unique key: `${username}@${serverHostname}` */
@@ -13,7 +13,7 @@ export interface AccountEntry {
username: string;
/** Authentication mode */
authMode: 'basic' | 'oauth';
/** Cookie slot index (04) for session/token cookies */
/** Cookie slot index for session/token cookies (0 ≤ slot < MAX_ACCOUNT_SLOTS) */
cookieSlot: number;
/** Whether "Remember Me" was checked (basic auth only) */
rememberMe: boolean;
@@ -80,8 +80,9 @@ export const useAccountStore = create<AccountState>()(
return id;
}
if (state.accounts.length >= MAX_ACCOUNTS) {
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
const max = getMaxAccounts();
if (state.accounts.length >= max) {
throw new Error(`Maximum of ${max} accounts reached`);
}
const cookieSlot = state.getNextCookieSlot();
@@ -178,10 +179,9 @@ export const useAccountStore = create<AccountState>()(
getNextCookieSlot: () => {
const used = new Set(get().accounts.map((a) => a.cookieSlot));
for (let i = 0; i < MAX_ACCOUNTS; i++) {
if (!used.has(i)) return i;
}
return 0; // fallback, shouldn't happen if max is enforced
let i = 0;
while (used.has(i)) i++;
return i;
},
hasAccount: (username, serverUrl) => {
+61 -32
View File
@@ -377,27 +377,30 @@ export const useAuthStore = create<AuthState>()(
const client = new JMAPClient(serverUrl, username, effectivePassword);
await client.connect();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
// Register in account store
// Resolve account/slot info up front so writes can start immediately.
const accountStore = useAccountStore.getState();
const accountId = generateAccountId(username, serverUrl);
const cookieSlot = accountStore.hasAccount(username, serverUrl)
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
: accountStore.getNextCookieSlot();
// Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
// Snapshot/clear before kicking off any feature-store fetches so they
// don't write into stores we're about to wipe.
const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId);
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
// 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 oauthAccessToken: string | null = null;
let oauthExpiresIn = 0;
@@ -439,6 +442,29 @@ export const useAuthStore = create<AuthState>()(
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
clients.set(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
@@ -468,27 +494,6 @@ export const useAuthStore = create<AuthState>()(
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({
isAuthenticated: true,
isLoading: false,
@@ -507,6 +512,15 @@ export const useAuthStore = create<AuthState>()(
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
if (upgradedToOAuth && oauthExpiresIn > 0) {
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
@@ -704,6 +718,12 @@ export const useAuthStore = create<AuthState>()(
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);
notifyParent('sso:auth-success', { username });
@@ -750,12 +770,17 @@ export const useAuthStore = create<AuthState>()(
const accountStore = useAccountStore.getState();
const slot = accountStore.getNextCookieSlot();
const ssoRes = await apiFetch('/api/auth/sso/complete', {
// SSO token exchange and config fetch are independent — fire both
// up front and let them resolve in parallel.
const [ssoRes, config] = await Promise.all([
apiFetch('/api/auth/sso/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state, slot }),
});
}),
fetchConfig(),
]);
if (!ssoRes.ok) {
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();
// We need the server URL from config
const config = await fetchConfig();
const ssoServerUrl = config.jmapServerUrl;
if (!ssoServerUrl) {
@@ -833,6 +856,12 @@ export const useAuthStore = create<AuthState>()(
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);
notifyParent('sso:auth-success', { username });
+34 -3
View File
@@ -72,10 +72,14 @@ interface EmailStore {
// JMAP operations
fetchMailboxes: (client: IJMAPClient) => 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>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
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>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: 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) => {
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
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 });
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
set({ isLoading: false });
} catch (error) {
+5 -1
View File
@@ -30,6 +30,7 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type ListDensity = Density;
export type DeleteAction = 'trash' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type SignaturePosition = 'above_quote' | 'below_quote';
export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h';
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 ToolbarPosition = 'top' | 'below-subject';
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 HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
@@ -143,6 +144,7 @@ interface SettingsState {
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
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
sessionTimeout: number; // minutes (0 = never)
@@ -295,6 +297,7 @@ const DEFAULT_SETTINGS = {
autoSelectReplyIdentity: false,
plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
signaturePosition: 'below_quote' as SignaturePosition,
// Privacy & Security
sessionTimeout: 0, // Never
@@ -466,6 +469,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter,
signaturePosition: state.signaturePosition,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
+25 -6
View File
@@ -11,6 +11,10 @@ const SIDEBAR_DEFAULT = 256;
const EMAIL_LIST_MIN = 240;
const EMAIL_LIST_MAX = 600;
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 {
// Mobile view state
@@ -28,6 +32,7 @@ interface UIState {
// Resizable column widths (desktop only)
sidebarWidth: number;
emailListWidth: number;
emailListHeight: number;
// Sidebar collapsed state (desktop)
sidebarCollapsed: boolean;
@@ -40,8 +45,10 @@ interface UIState {
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
setSidebarWidth: (width: number) => void;
setEmailListWidth: (width: number) => void;
setEmailListHeight: (height: number) => void;
resetSidebarWidth: () => void;
resetEmailListWidth: () => void;
resetEmailListHeight: () => void;
persistColumnWidths: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void;
@@ -64,6 +71,7 @@ export const useUIStore = create<UIState>((set, get) => ({
isDesktop: true,
sidebarWidth: SIDEBAR_DEFAULT,
emailListWidth: EMAIL_LIST_DEFAULT,
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
sidebarCollapsed: false,
// Actions
@@ -84,26 +92,37 @@ export const useUIStore = create<UIState>((set, get) => ({
setEmailListWidth: (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: () => {
set({ sidebarWidth: SIDEBAR_DEFAULT });
const { emailListWidth } = get();
const { emailListWidth, emailListHeight } = get();
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 */ }
},
resetEmailListWidth: () => {
set({ emailListWidth: EMAIL_LIST_DEFAULT });
const { sidebarWidth } = get();
const { sidebarWidth, emailListHeight } = get();
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 */ }
},
persistColumnWidths: () => {
const { sidebarWidth, emailListWidth } = get();
const { sidebarWidth, emailListWidth, emailListHeight } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth }));
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},