diff --git a/.env.example b/.env.example index c1733ba8..c2f2b40c 100644 --- a/.env.example +++ b/.env.example @@ -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 # ============================================================================= diff --git a/.gitignore b/.gitignore index 162a9448..db1d86e7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,4 @@ next-env.d.ts /local-data/ # Sibling repos -/repos/ \ No newline at end of file +/repos/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4579685d..024db29e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,154 @@ # Changelog +## 1.6.7 (2026-05-17) + +### Features + +- **Contacts**: vCard 4.0 parsing and generation support +- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page +- **Admin**: Allow admin password overwrite during setup recovery +- **Setup**: HTTPS requirement warning in the setup wizard +- **Mobile**: Show details toggle and expandable panel for sender info + +### Performance + +- **Calendar**: Speed up calendar invitation banner load + +### Security + +- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `` tag +- **Admin**: Redact sensitive config secrets from the admin API response +- **Admin**: Make impersonation cookies session-only + +### Fixes + +- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time +- **Auth**: Use a relative `Location` header in redirects +- **Auth**: Adopt orphan session cookie on first SPA load +- **Mail**: Per-account push subscriptions so multi-account notifications work (#298) +- **Mail**: Close attachment preview when clicking outside the content area +- **Mail**: Pin quick reply to the bottom for short emails +- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails +- **Mail**: Show contact popup when clicking the sender name in the email header +- **Mail**: Prevent long addresses from overflowing email details columns (#297) +- **Mobile**: Align quick reply with the mobile bottom toolbar +- **Mobile**: Respect safe-area insets on mobile bottom bars +- **Mobile**: Pad `safe-area-inset-top` +- **UI**: Apply dark background to the email content wrapper in dark mode +- **UI**: Improve dark mode background colors in the email viewer +- **UI**: Add viewport export with `initialScale: 1` +- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account +- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion` +- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES` +- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install +- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing + +### i18n + +- Add missing translation keys across 16 locales + +## 1.6.6 (2026-05-15) + +### Features + +- **Mail**: Sync onboarding completion state across devices so the welcome flow only runs once per account (#285) +- **Mail**: Distinct icons for Shared, Important, Memos, Scheduled, and Snoozed folders (#288) +- **Compose**: Raise HTML identity signature length cap to 50,000 characters +- **Compose**: Allow `` tags in HTML identity signatures for inline logos and banners + +### Fixes + +- **Files**: Hide Files settings entry and sidebar nav when the `filesEnabled` policy is off (#291) +- **Admin**: Honor the `cookieSameSite` admin config override instead of always defaulting (#284) +- **UI**: Standardize punctuation in tooltips and inline comments across locales + +### i18n + +- Add Danish localization +- Clean up Danish locale wiring and sort the language picker alphabetically (#286) + +## 1.6.5 (2026-05-13) + +### Features + +- **Protocol**: Register as the system handler for `mailto:` and `webcal:` links from a new protocol handler settings page +- **Protocol**: Account picker for protocol links when multiple accounts are connected +- **Protocol**: Import-or-subscribe choice for detected webcal calendars +- **Protocol**: Reuse the open PWA/session for `mailto:` links instead of always opening a new tab +- **UI**: Route account avatars through the shared `Avatar` component for consistent fallbacks (#278) + +### Fixes + +- **Calendar**: Support HTTP basic auth in iCal subscription URLs (#275) +- **Admin**: Honor admin-uploaded favicon in root metadata (#274) +- **Admin**: Honor `NEXT_PUBLIC_BASE_PATH` in admin sidebar nav links (#271) +- **UI**: Broaden body font stack so Thai (and other non-Latin scripts) render correctly in subjects, sender names, and other chrome (#265) + +## 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 981fa363..7d941e32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,14 +10,17 @@ # Contributing to Bulwark Webmail -Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. +We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale. -## Join our Community -**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect. +If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change. -* **Get Support:** Get real-time help with development hurdles. -* **Contribute:** Share ideas, suggest features, or help us improve documentation. -* **Collaborate:** Meet the team and other contributors working to make Bulwark better. +## Join the Community + +You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this. + +- **Get support** - real-time help with development hurdles +- **Share ideas** - feature suggestions, design feedback, doc improvements +- **Collaborate** - meet the team and other contributors [**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT) @@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks. ## Internationalization (i18n) -This project uses **next-intl** for internationalization. Please follow these guidelines: +This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh). -### Key Rules +### Rules -1. **Never hardcode user-facing text** - Always use translations: +1. **Never hardcode user-facing text** - always use translations: ```tsx const t = useTranslations("namespace"); return
{t("key")}
; ``` -2. **Translation file locations**: - - English: `/locales/en/common.json` - - French: `/locales/fr/common.json` +2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English. 3. **Namespace organization**: - - `login.*` - Login page strings - - `sidebar.*` - Sidebar navigation - - `email_list.*` - Email list component - - `email_viewer.*` - Email viewer component - - `email_composer.*` - Email composer - - `common.*` - Shared strings - - `notifications.*` - Toast/alert messages - - `settings.*` - Settings page + - `login.*` - login page + - `sidebar.*` - sidebar navigation + - `email_list.*` - email list + - `email_viewer.*` - email viewer + - `email_composer.*` - composer + - `settings.*` - settings page + - `notifications.*` - toasts and alerts + - `common.*` - shared strings -4. **Adding new strings**: - - Add to **both** English and French translation files - - Use descriptive, hierarchical keys - - Keep translations consistent in tone +4. **Locale-aware navigation**: -5. **Locale-aware navigation**: ```tsx router.push(`/${params.locale}/settings`); ``` @@ -203,16 +200,11 @@ webmail/ ## Security -- **Never commit sensitive data** (API keys, passwords, etc.) +- **Never commit secrets** - API keys, passwords, tokens, `.env*` files - **Sanitize user input** and email content -- **Block external content** by default for privacy -- Report security vulnerabilities privately (e.g. bulwark@rbm.systems) +- **Block external content** by default - privacy is the point +- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues ## Questions? -If you have questions about contributing, feel free to: - -- Open an issue for discussion -- Check existing issues and pull requests - -Thank you for helping improve Bulwark Webmail! +Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed. diff --git a/Dockerfile b/Dockerfile index 20084be2..8e3a3e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/FEATURES.md b/FEATURES.md index aac5e389..fb6e2f3e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 @@ -94,31 +98,38 @@ ## Internationalization -15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська +17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. ## 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 diff --git a/README.md b/README.md index eb830e07..2b673748 100644 --- a/README.md +++ b/README.md @@ -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.7-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. + + + + Setup wizard + + +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 @@ -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 +
+Admin data directories + +```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`. + +
+
Subpath / reverse proxy mount diff --git a/VERSION b/VERSION index 308b6faa..400084b1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.2 \ No newline at end of file +1.6.7 diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index b03e2313..efe701cb 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -78,7 +78,69 @@ function OAuthCallbackInner() { setError("token_exchange_failed"); }); } else if (state) { - // Server-side SSO flow - state was stored in encrypted httpOnly cookie + // Server-side SSO flow - state was stored in encrypted httpOnly cookie. + // Branch on mobile handoff first: the login page left a marker in + // sessionStorage if it kicked this OAuth dance off for the mobile app. + let mobileRedirectUri: string | null = null; + let mobileState: string | null = null; + try { + mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri"); + mobileState = sessionStorage.getItem("mobile_state"); + } catch { /* sessionStorage may be unavailable */ } + + if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { + // Drive /api/auth/sso/complete directly so we can read the tokens + // out of the response — loginWithServerSso would consume them and + // wire up the webmail auth store, which isn't useful here. The + // server's mobile-flow branch (keyed on the pending cookie) skips + // the refresh-token cookie write for the same reason. + (async () => { + try { + const res = await fetch("/api/auth/sso/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ code, state }), + }); + if (!res.ok) { + setError("token_exchange_failed"); + return; + } + const data = await res.json(); + const serverUrl = data.server_url as string | undefined; + const accessToken = data.access_token as string | undefined; + const tokenEndpoint = data.token_endpoint as string | undefined; + const clientId = data.client_id as string | undefined; + if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) { + setError("token_exchange_failed"); + return; + } + const fragment = new URLSearchParams({ + flow: "oauth", + server_url: serverUrl, + access_token: accessToken, + token_endpoint: tokenEndpoint, + client_id: clientId, + state: mobileState ?? "", + }); + if (typeof data.refresh_token === "string") { + fragment.set("refresh_token", data.refresh_token); + } + if (typeof data.expires_in === "number") { + fragment.set("expires_in", String(data.expires_in)); + } + try { + sessionStorage.removeItem("mobile_redirect_uri"); + sessionStorage.removeItem("mobile_state"); + } catch { /* ignore */ } + window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); + } catch { + setError("token_exchange_failed"); + } + })(); + return; + } + const ssoPrefix = getPathPrefix(params.locale as string); loginWithServerSso(code, state) .then((success) => { diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index c64b6424..138c8717 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useAccountStore } from "@/stores/account-store"; import { toast } from "@/stores/toast-store"; import { useIsMobile } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -31,17 +32,20 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventContextMenu } from "@/components/calendar/event-context-menu"; +import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { downloadEventICS } from "@/lib/calendar-ics-export"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; +import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { getEventStartDate } from "@/lib/calendar-utils"; @@ -56,6 +60,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal import { getUserParticipantId } from "@/lib/calendar-participants"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { debug } from "@/lib/debug"; +import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; +import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -68,9 +74,11 @@ function isRecurringEvent(event: CalendarEvent): boolean { export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); + const tWebcalAction = useTranslations("calendar.webcal_action"); const isMobile = useIsMobile(); + const isEmbedded = useIsEmbedded(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); - const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { @@ -96,6 +104,10 @@ export default function CalendarPage() { const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); + const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null); + const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false); + const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState(null); + const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); const [sharingCalendarId, setSharingCalendarId] = useState(null); const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState(undefined); @@ -156,10 +168,10 @@ export default function CalendarPage() { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } redirectToLogin(); - } else if (client && !supportsCalendar) { + } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) { router.push("/"); } - }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); useEffect(() => { if (error) { @@ -167,6 +179,84 @@ export default function CalendarPage() { } }, [error]); + const getWebcalProtocolAccounts = useCallback(() => { + const connectedClients = useAuthStore.getState().getAllConnectedClients(); + return useAccountStore.getState().accounts.filter((account) => { + if (!account.isConnected) return false; + return connectedClients.get(account.id)?.supportsCalendars() === true; + }); + }, []); + + const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => { + setIsProtocolAccountSwitching(true); + try { + if (useAuthStore.getState().activeAccountId !== accountId) { + await switchAccount(accountId); + } + setPendingWebcalAccountChoice(null); + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + setShowWebcalActionChoice(true); + } finally { + setIsProtocolAccountSwitching(false); + } + }, [switchAccount]); + + const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => { + const protocolAccounts = getWebcalProtocolAccounts(); + if (protocolAccounts.length > 1) { + setPendingWebcalAccountChoice(pending); + return; + } + + if (protocolAccounts.length === 0 && !supportsCalendar) { + return; + } + + const accountId = protocolAccounts[0]?.id ?? activeAccountId; + if (accountId) { + void openWebcalForAccount(pending, accountId); + return; + } + + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + setShowWebcalActionChoice(true); + }, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]); + + const closeWebcalActionChoice = useCallback(() => { + setShowWebcalActionChoice(false); + setPendingSubscription(null); + }, []); + + const handleImportWebcal = useCallback(() => { + setShowWebcalActionChoice(false); + setShowImportModal(true); + }, []); + + const handleSubscribeWebcal = useCallback(() => { + setShowWebcalActionChoice(false); + setShowSubscriptionModal(true); + }, []); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const openPendingWebcal = () => { + const pending = consumePendingWebcal(); + if (!pending) return; + + handleWebcalProtocolRequest(pending); + }; + + openPendingWebcal(); + return subscribeToPendingWebcal(openPendingWebcal); + }, [isAuthenticated, client, handleWebcalProtocolRequest]); + useEffect(() => { if (client && !hasFetched.current) { hasFetched.current = true; @@ -955,7 +1045,54 @@ export default function CalendarPage() { }); }, [events, selectedCalendarIds, visibleEvents]); - if (!isAuthenticated || !supportsCalendar) return null; + const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? ( + void openWebcalForAccount(pendingWebcalAccountChoice, accountId)} + onCancel={() => setPendingWebcalAccountChoice(null)} + /> + ) : null; + + const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? ( +
+ + ) : null; + + if (!isAuthenticated) return null; + if (!supportsCalendar) return renderWebcalAccountPicker(); const renderView = () => { if (isLoading && calendars.length === 0) { @@ -1082,9 +1219,11 @@ export default function CalendarPage() { }; return ( -
- {/* Left Navigation Rail */} - {!isMobile && ( +
+ +
+ {/* Left Navigation Rail (hidden when embedded in Pro shell) */} + {!isMobile && !isEmbedded && (
setShowImportModal(false)} + initialUrl={pendingSubscription?.url} + onClose={() => { + setShowImportModal(false); + setPendingSubscription(null); + }} /> )} {showSubscriptionModal && client && ( setShowSubscriptionModal(false)} + initialUrl={pendingSubscription?.url} + initialName={pendingSubscription?.name} + onClose={() => { + setShowSubscriptionModal(false); + setPendingSubscription(null); + }} /> )} @@ -1402,6 +1550,8 @@ export default function CalendarPage() { })()} + {renderWebcalAccountPicker()} + {renderWebcalActionChoice()} ); })()} +
); } diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 8bcc3891..cfc88b35 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { ArrowLeft, Users } from "lucide-react"; +import { ArrowLeft, Users, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; @@ -15,15 +15,18 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { RenameDialog } from "@/components/files/rename-dialog"; import { exportContacts } from "@/components/contacts/contact-export"; +import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; +import { usePolicyStore } from "@/stores/policy-store"; import { toast } from "@/stores/toast-store"; import { cn, generateUUID } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; @@ -42,6 +45,7 @@ type View = export default function ContactsPage() { const t = useTranslations("contacts"); + const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); @@ -93,6 +97,7 @@ export default function ContactsPage() { const hasFetched = useRef(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); + const isEmbedded = useIsEmbedded(); // Panel resize state - sidebar (categories) const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -640,6 +645,18 @@ export default function ContactsPage() { } }; + if (!contactsEnabled) { + return ( +
+
+ +

Contacts feature is disabled by your administrator

+

Please contact your administrator if you need access.

+
+
+ ); + } + const showListPanel = !isMobile || view === "list"; const showRightPanel = !isMobile || view !== "list"; @@ -649,9 +666,11 @@ export default function ContactsPage() { }; return ( -
- {/* Navigation Rail - desktop only */} - {!isMobile && ( +
+ +
+ {/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */} + {!isMobile && !isEmbedded && (
- {isMobile && ( + {isMobile && !isEmbedded && ( ); })()} +
); } diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index 261e73d6..7eb2f7b1 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -16,6 +16,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { usePolicyStore } from "@/stores/policy-store"; @@ -24,6 +25,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog"; +import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { AlertTriangle } from "lucide-react"; export default function FilesPage() { @@ -83,6 +85,7 @@ export default function FilesPage() { } = useFileStore(); const isMobile = useIsMobile(); + const isEmbedded = useIsEmbedded(); const [folderLayout, setFolderLayout] = useState(() => loadFilesSettings().folderLayout); const hasFetched = useRef(false); @@ -374,8 +377,10 @@ export default function FilesPage() { if (!isAuthenticated) return null; return ( -
- {!isMobile && ( +
+ +
+ {!isMobile && !isEmbedded && (
- {isMobile && ( + {isMobile && !isEmbedded && ( +
); } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 12b1a4e3..238de5b7 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -5,6 +5,9 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { TourProvider } from "@/components/tour/tour-provider"; +import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider"; +import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { locales } from "@/i18n/routing"; export default async function LocaleLayout({ @@ -32,7 +35,11 @@ export default async function LocaleLayout({ - {children} + + {children} + + + diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 2b974a63..a998af52 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,7 +16,6 @@ import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; -import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; @@ -109,15 +108,32 @@ function VersionBadge() { ); } +// Only redirect targets matching this scheme are honored by the mobile +// handoff path. Without the check the login page becomes an open redirector +// that funnels password and token material to any caller-supplied URL. +const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://"; + export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; + + // When the mobile app launches the webmail in a browser tab it tacks on + // these params. We grab them once at mount and stash them in a ref so any + // login path that completes (password or OAuth) can hand control back to + // the app instead of routing into /mail. + const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? ""; + const rawMobileState = searchParams.get("mobile_state") ?? ""; + const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME) + ? rawMobileRedirectUri + : ""; + const mobileState = mobileRedirectUri ? rawMobileState : ""; + const isMobileHandoff = Boolean(mobileRedirectUri); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); + const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ @@ -160,6 +176,9 @@ export default function LoginPage() { const totpInputRef = useRef(null); const prevError = useRef(null); const themeMenuRef = useRef(null); + // Captured by handleSubmit when in mobile handoff mode; consumed by the + // isAuthenticated effect to build the deep-link fragment. + const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null); useEffect(() => { initializeTheme(); @@ -239,6 +258,19 @@ export default function LoginPage() { useEffect(() => { if (isAuthenticated && !isAddAccountMode) { + // Mobile handoff: the password path completes here once the auth store + // flips isAuthenticated. Hand the verified credentials back to the + // mobile app instead of pushing to /mail. handleSubmit captured the + // values needed for the fragment. + if (isMobileHandoff && mobileHandoffPayloadRef.current) { + const fragment = new URLSearchParams({ + flow: "password", + ...mobileHandoffPayloadRef.current, + state: mobileState, + }); + window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); + return; + } let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); @@ -249,7 +281,7 @@ export default function LoginPage() { } catch { /* ignore */ } router.push(redirectTo); } - }, [isAuthenticated, router, isAddAccountMode]); + }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]); useEffect(() => { clearError(); @@ -317,6 +349,16 @@ export default function LoginPage() { try { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + // In mobile-handoff mode the callback page needs to know it should + // redirect into the app rather than into /mail. Stash the params in + // sessionStorage so the same-tab callback can read them — the SSO + // pending cookie carries the authoritative copy server-side too. + if (isMobileHandoff) { + try { + sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri); + sessionStorage.setItem("mobile_state", mobileState); + } catch { /* sessionStorage unavailable */ } + } const res = await apiFetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -325,6 +367,9 @@ export default function LoginPage() { redirect_uri: redirectUri, locale: params.locale, server_id: selectedServer?.id, + ...(isMobileHandoff + ? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState } + : {}), }), }); @@ -351,7 +396,7 @@ export default function LoginPage() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id]); + }, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]); useEffect(() => { if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; @@ -493,6 +538,15 @@ export default function LoginPage() { const handleOAuthLogin = async () => { if (!oauthMetadata || !effectiveOauthClientId) return; + // In mobile-handoff mode the client-side PKCE flow doesn't help us: + // tokens would land in sessionStorage on the webmail origin and the + // mobile app couldn't read them. Route through the server-side SSO + // path instead, which has the mobile-aware /api/auth/sso/complete + // branch. + if (isMobileHandoff) { + await startServerSideSso(); + return; + } setOauthLoading(true); const verifier = generateCodeVerifier(); @@ -532,7 +586,7 @@ export default function LoginPage() { authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", effectiveOauthClientId); authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("scope", OAUTH_SCOPES); + authUrl.searchParams.set("scope", oauthScopes || "openid email profile"); authUrl.searchParams.set("state", state); authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge_method", "S256"); @@ -547,6 +601,16 @@ export default function LoginPage() { // when the admin hasn't configured a server list. const effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); + // Capture before login() so the isAuthenticated effect can build the + // deep-link fragment with values the user actually typed (formData may + // be cleared by the auth store on success). + if (isMobileHandoff) { + mobileHandoffPayloadRef.current = { + server_url: effectiveServerUrl, + username: formData.username, + password: formData.password, + }; + } const success = await login( effectiveServerUrl, formData.username, @@ -557,7 +621,15 @@ export default function LoginPage() { if (success) { saveUsername(formData.username); + if (isMobileHandoff) { + // The isAuthenticated effect handles the redirect; nothing else to + // do here. Don't push to / — that would race the deep link. + return; + } router.push('/'); + } else if (isMobileHandoff) { + // Stale payload should never feed into a later retry's redirect. + mobileHandoffPayloadRef.current = null; } }; diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 2006807c..ae61a96f 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; +import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; @@ -48,19 +49,29 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIdentitySync } from "@/hooks/use-identity-sync"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import { useProTabStore } from "@/stores/pro-tab-store"; import { Input } from "@/components/ui/input"; 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"; import { useConfig } from "@/hooks/use-config"; import { usePluginStore } from "@/stores/plugin-store"; +import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useThemeStore } from "@/stores/theme-store"; +import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; +import { plainTextToComposerBody } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; +import { buildQuoteHeader } from "@/lib/quote-header"; +import { useLocaleStore } from "@/stores/locale-store"; +import type { QuoteHeader } from "@/lib/plugin-types"; const SCHEDULED_MAILBOX_ID = '__scheduled__'; @@ -75,6 +86,10 @@ export default function Home() { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); + // Plugin-resolved quote header for the next reply/forward composer open. + // Cleared on close so a subsequent "compose new" doesn't reuse stale state. + const [composerQuoteHeader, setComposerQuoteHeader] = useState(null); + const suppressComposerStateSaveSessionRef = useRef(null); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -90,8 +105,10 @@ export default function Home() { const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); + const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); @@ -204,7 +221,8 @@ 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 isEmbedded = useIsEmbedded(); + const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(); const { emails, mailboxes, @@ -327,6 +345,13 @@ export default function Home() { [], ); + const getMailtoProtocolAccounts = useCallback(() => { + const connectedClients = useAuthStore.getState().getAllConnectedClients(); + return useAccountStore.getState().accounts.filter((account) => + account.isConnected && connectedClients.has(account.id) + ); + }, []); + // Browser back / forward integration. The restore handler reads the // latest values from a ref so we don't have to recreate the callback on // every render (and so the popstate listener is never stale). @@ -671,6 +696,65 @@ export default function Home() { document.title = title; }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); + // When this page is rendered inside the Pro shell as the Mail tab body, + // we hoist every "show composer" intent into its own Pro tab and reset + // the in-page state so the inline composer never appears in the Mail tab. + // This makes the Pro composer behave like Thunderbird's pop-out window. + useEffect(() => { + if (!isEmbedded || !showComposer) return; + const replyTo = selectedEmail ? { + from: selectedEmail.from, + replyToAddresses: selectedEmail.replyTo, + to: selectedEmail.to, + cc: selectedEmail.cc, + bcc: selectedEmail.bcc, + subject: selectedEmail.subject, + body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', + htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, + receivedAt: selectedEmail.receivedAt, + attachments: selectedEmail.attachments, + messageId: selectedEmail.messageId, + inReplyTo: selectedEmail.inReplyTo, + references: selectedEmail.references, + quoteHeaderHtml: composerQuoteHeader?.html, + quoteHeaderText: composerQuoteHeader?.text, + quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote, + } : undefined; + + const effectiveMode = pendingDraft?.mode ?? composerMode; + const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? ''; + let title = t('email_composer.new_message'); + if (baseSubject) { + if (effectiveMode === 'reply' || effectiveMode === 'replyAll') { + title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`; + } else if (effectiveMode === 'forward') { + title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`; + } else { + title = baseSubject; + } + } + + useProTabStore.getState().openComposeTab({ + sessionId: composerSessionId + 1, + mode: effectiveMode, + replyTo, + initialDraftText: composerDraftText, + initialData: pendingDraft, + sourceEmailId: selectedEmail?.id ?? null, + title, + }); + + setComposerSessionId((s) => s + 1); + setShowComposer(false); + setComposerDraftText(""); + setPendingDraft(null); + setComposerQuoteHeader(null); + // We only react to the rising edge of `showComposer` here; the other + // variables read above are captured-but-stale-safe because the next + // open will fire a fresh effect with new values. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEmbedded, showComposer]); + // Check auth on mount – skip when already authenticated so that navigating // between routes doesn't retrigger checkAuth's transient `{ client: null, // isLoading: true }` reset, which was flashing the spinner on every nav. @@ -700,6 +784,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 @@ -713,7 +798,78 @@ export default function Home() { } }, [initialCheckDone, isAuthenticated, authLoading]); - // Load mailboxes and emails when authenticated (only if not already loaded) + const openMailtoDraft = useCallback((pending: ParsedMailto) => { + const body = useSettingsStore.getState().plainTextMode + ? pending.body + : plainTextToComposerBody(pending.body); + + if (showComposer) { + suppressComposerStateSaveSessionRef.current = composerSessionId; + } + setComposerSessionId((id) => id + 1); + setPendingDraft({ + to: pending.to.join(", "), + cc: pending.cc.join(", "), + bcc: pending.bcc.join(", "), + subject: pending.subject, + body, + showCc: pending.cc.length > 0, + showBcc: pending.bcc.length > 0, + selectedIdentityId: null, + subAddressTag: "", + mode: "compose", + draftId: null, + }); + setComposerMode("compose"); + setShowComposer(true); + if (isMobile) setActiveView("viewer"); + }, [composerSessionId, isMobile, setActiveView, showComposer]); + + const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { + setIsProtocolAccountSwitching(true); + try { + if (useAuthStore.getState().activeAccountId !== accountId) { + await switchAccount(accountId); + } + setPendingMailtoAccountChoice(null); + openMailtoDraft(pending); + } finally { + setIsProtocolAccountSwitching(false); + } + }, [openMailtoDraft, switchAccount]); + + const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => { + const protocolAccounts = getMailtoProtocolAccounts(); + if (protocolAccounts.length > 1) { + setPendingMailtoAccountChoice(pending); + return; + } + + const accountId = protocolAccounts[0]?.id ?? activeAccountId; + if (accountId) { + void openMailtoForAccount(pending, accountId); + return; + } + + openMailtoDraft(pending); + }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const openPendingMailto = () => { + const pending = consumePendingMailto(); + if (pending) handleMailtoProtocolRequest(pending); + }; + + openPendingMailto(); + return subscribeToPendingMailto(openPendingMailto); + }, [isAuthenticated, client, handleMailtoProtocolRequest]); + + // 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 | null = null; @@ -721,18 +877,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`); @@ -749,27 +901,7 @@ export default function Home() { 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); } @@ -779,17 +911,33 @@ export default function Home() { return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); - client.closePushNotifications(); }; } + }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]); - // Cleanup push notifications on unmount - return () => { - if (client) { - client.closePushNotifications(); + // 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); + } + + return () => { + client.closePushNotifications(); }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata, 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 @@ -941,6 +1089,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[]; @@ -952,7 +1101,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - const result = 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.delayedUntil); + const result = 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.delayedUntil, data.envelopeMailFrom); setShowComposer(false); if (result.scheduled) { await refreshScheduledMetadata(client); @@ -993,6 +1142,50 @@ export default function Home() { } }; + // Build the quote header for a reply/forward open, running it through the + // emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores + // the result in composerQuoteHeader; the render site spreads it into + // EmailComposer.replyTo. Errors fall back to the composer's built-in + // header (state set to null). + const prepareComposerQuoteHeader = useCallback(async ( + email: Email | null, + mode: 'reply' | 'replyAll' | 'forward', + ) => { + if (!email) { setComposerQuoteHeader(null); return; } + try { + const replyTargets = (email.replyTo?.length + ? email.replyTo + : email.from ?? []).filter(r => r.email).map(r => r.email!); + const newTo = mode === 'reply' + ? replyTargets + : mode === 'replyAll' + ? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)] + : []; + const newCc = mode === 'replyAll' + ? (email.cc ?? []).filter(r => r.email).map(r => r.email!) + : []; + const header = await buildQuoteHeader({ + mode, + email: { + from: email.from, + to: email.to, + cc: email.cc, + subject: email.subject, + receivedAt: email.receivedAt, + }, + newTo, + newCc, + locale: useLocaleStore.getState().locale, + timeFormat: useSettingsStore.getState().timeFormat, + unknownLabel: tCommon('unknown'), + }); + setComposerQuoteHeader(header); + } catch (err) { + console.warn('[quote-header] plugin transform failed; using default', err); + setComposerQuoteHeader(null); + } + }, [tCommon]); + const handleReply = async (draftText?: string) => { if (selectedEmail) { const ok = await emailHooks.onBeforeReply.intercept({ @@ -1001,6 +1194,9 @@ export default function Home() { mode: 'reply' as const, }); if (!ok) return; + await prepareComposerQuoteHeader(selectedEmail, 'reply'); + } else { + setComposerQuoteHeader(null); } setComposerDraftText(draftText || ""); setComposerMode('reply'); @@ -1068,6 +1264,9 @@ export default function Home() { mode: 'reply-all' as const, }); if (!ok) return; + await prepareComposerQuoteHeader(selectedEmail, 'replyAll'); + } else { + setComposerQuoteHeader(null); } setComposerMode('replyAll'); setShowComposer(true); @@ -1082,6 +1281,9 @@ export default function Home() { mode: 'forward' as const, }); if (!ok) return; + await prepareComposerQuoteHeader(selectedEmail, 'forward'); + } else { + setComposerQuoteHeader(null); } setComposerMode('forward'); setShowComposer(true); @@ -1597,6 +1799,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 { @@ -1697,9 +1936,30 @@ 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, { + separator: useSettingsStore.getState().signatureSeparatorEnabled, + }); const originalEmailId = selectedEmail.id; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; @@ -1727,15 +1987,16 @@ 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, delayedUntil, + envelopeMailFrom, ); if (result.scheduled) { @@ -1769,9 +2030,11 @@ export default function Home() { // Get current mailbox name for mobile header const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : 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 }) => { @@ -1892,22 +2155,25 @@ export default function Home() { }; // Handle reply from conversation view - const handleConversationReply = (email: Email) => { + const handleConversationReply = async (email: Email) => { selectEmail(email); + await prepareComposerQuoteHeader(email, 'reply'); setComposerMode('reply'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; - const handleConversationReplyAll = (email: Email) => { + const handleConversationReplyAll = async (email: Email) => { selectEmail(email); + await prepareComposerQuoteHeader(email, 'replyAll'); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; - const handleConversationForward = (email: Email) => { + const handleConversationForward = async (email: Email) => { selectEmail(email); + await prepareComposerQuoteHeader(email, 'forward'); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -1935,7 +2201,8 @@ export default function Home() { return ( -
+
+ {isRateLimited && rateLimitSecondsLeft !== null && (
@@ -1950,8 +2217,8 @@ export default function Home() {
)}
- {/* Desktop Navigation Rail */} - {!isMobile && !isTablet && ( + {/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */} + {!isMobile && !isTablet && !isEmbedded && (
)} - {/* Mobile/Tablet Sidebar Overlay Backdrop */} + {/* Mobile/Tablet Sidebar Overlay Backdrop. + When embedded in a Pro pane the viewport is desktop-wide, so the + `lg:hidden` viewport-variant alone wouldn't gate this overlay; + scope to the pane via `absolute` so the backdrop stays inside + the pane instead of covering the whole window. */} {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
setSidebarOpen(false)} /> )} - {/* Sidebar - overlay on mobile/tablet, fixed on desktop */} + {/* Sidebar - overlay on mobile/tablet, in-flow on desktop. + When embedded, overlay-mode is driven by pane-aware JS rather + than viewport-variants (which still see the full window). */}
{ setComposerMode('compose'); @@ -2037,21 +2324,30 @@ export default function Home() { {/* Main Content Area */}
-
- {/* Email List - full width on mobile, fixed width on tablet/desktop */} +
+ {/* Email List - full width on mobile, fixed width/height on tablet/desktop */} {/* Email list resize handle (desktop only) */} - {!isMobile && !isTablet && !isFocusedMailLayout && ( + {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && ( { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} @@ -2398,26 +2702,41 @@ export default function Home() { onDoubleClick={resetEmailListWidth} /> )} + {!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && ( + { 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 */}
- {/* Inline Composer - shown in viewer pane */} - {showComposer ? ( + {/* Inline Composer - shown in viewer pane. + In Pro/embedded mode the composer is hoisted into its own + Pro tab (see the effect below), so we never render it inline. */} + {(showComposer && !isEmbedded) ? ( { setShowComposer(false); setComposerMode('compose'); + setComposerQuoteHeader(null); }} > setPendingDraft(data)} + onSaveState={(data) => { + if (suppressComposerStateSaveSessionRef.current === composerSessionId) { + suppressComposerStateSaveSessionRef.current = null; + return; + } + setPendingDraft(data); + }} onSend={async (data) => { await handleEmailSend(data); setPendingDraft(null); @@ -2459,6 +2787,7 @@ export default function Home() { setComposerMode('compose'); setComposerDraftText(""); setPendingDraft(null); + setComposerQuoteHeader(null); if (isMobile) { setActiveView('list'); } @@ -2590,8 +2919,8 @@ export default function Home() {
- {/* Bottom Navigation - mobile and tablet */} - {(isMobile || isTablet) && activeView !== "viewer" && ( + {/* Bottom Navigation - mobile and tablet (hidden when embedded) */} + {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && ( + {pendingMailtoAccountChoice && ( + void openMailtoForAccount(pendingMailtoAccountChoice, accountId)} + onCancel={() => setPendingMailtoAccountChoice(null)} + /> + )} {pendingUndoSend && ( diff --git a/app/[locale]/pro/page.tsx b/app/[locale]/pro/page.tsx new file mode 100644 index 00000000..14d2bb79 --- /dev/null +++ b/app/[locale]/pro/page.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react"; +import { useTranslations } from "next-intl"; +import { NavigationRail } from "@/components/layout/navigation-rail"; +import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; +import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; +import { InlineAppView } from "@/components/layout/inline-app-view"; +import { useSidebarApps } from "@/hooks/use-sidebar-apps"; +import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useDeviceDetection } from "@/hooks/use-media-query"; +import { EmbeddedContext } from "@/hooks/use-is-embedded"; +import { PaneSizeContext } from "@/hooks/use-pane-size"; +import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar"; +import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store"; +import { cn } from "@/lib/utils"; + +import MailPage from "@/app/[locale]/page"; +import CalendarPage from "@/app/[locale]/calendar/page"; +import ContactsPage from "@/app/[locale]/contacts/page"; +import FilesPage from "@/app/[locale]/files/page"; +import SettingsPage from "@/app/[locale]/settings/page"; +import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body"; +import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body"; + +const APP_TAB_COMPONENTS: Partial> = { + mail: MailPage, + calendar: CalendarPage, + contacts: ContactsPage, + files: FilesPage, + settings: SettingsPage, +}; + +type DropTarget = 'left' | 'right' | null; + +function renderTabBody(tab: ProTab): React.ReactNode { + if (tab.kind === 'compose' && tab.composeData) { + return ; + } + if (tab.kind === 'email' && tab.emailData) { + return ; + } + const Component = APP_TAB_COMPONENTS[tab.kind]; + return Component ? : null; +} + +interface PaneProps { + paneId: ProPaneId; + tabs: ProTab[]; + activeTabId: string | null; + loadedTabIds: string[]; + onPaneFocus: (paneId: ProPaneId) => void; + isFocused: boolean; +} + +function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) { + const paneRef = useRef(null); + // Measured pane width, published to children via PaneSizeContext so that + // useDeviceDetection / useIsMobile / etc. branch on pane width — not full + // viewport — and inner pages collapse to their mobile/tablet layouts when + // the pane is narrow. + const [paneWidth, setPaneWidth] = useState(null); + + useEffect(() => { + const el = paneRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const initialRect = el.getBoundingClientRect(); + if (initialRect.width > 0) setPaneWidth(initialRect.width); + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const w = entry.contentRect.width; + setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return ( +
{ if (!isFocused) onPaneFocus(paneId); }} + > + + {tabs + .filter((tab) => loadedTabIds.includes(tab.id)) + .map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ {renderTabBody(tab)} +
+ ); + })} +
+
+ ); +} + +export default function ProHome() { + const t = useTranslations(); + const { isMobile, isTablet, isDesktop } = useDeviceDetection(); + + const [initialCheckDone, setInitialCheckDone] = useState( + () => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client + ); + const [showShortcutsModal, setShowShortcutsModal] = useState(false); + const { + showAppsModal, + inlineApp, + loadedApps, + handleManageApps, + handleInlineApp, + closeInlineApp, + closeAppsModal, + } = useSidebarApps(); + + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const client = useAuthStore((s) => s.client); + const logout = useAuthStore((s) => s.logout); + const checkAuth = useAuthStore((s) => s.checkAuth); + const authLoading = useAuthStore((s) => s.isLoading); + const quota = useEmailStore((s) => s.quota); + const isPushConnected = useEmailStore((s) => s.isPushConnected); + + const tabs = useProTabStore((s) => s.tabs); + const activeMainTabId = useProTabStore((s) => s.activeTabId); + const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId); + const splitOrientation = useProTabStore((s) => s.splitOrientation); + const focusedPaneId = useProTabStore((s) => s.focusedPaneId); + const loadedTabIds = useProTabStore((s) => s.loadedTabIds); + const openTab = useProTabStore((s) => s.openTab); + const closeTab = useProTabStore((s) => s.closeTab); + const setActiveTab = useProTabStore((s) => s.setActiveTab); + const setFocusedPane = useProTabStore((s) => s.setFocusedPane); + const moveTabToPane = useProTabStore((s) => s.moveTabToPane); + + const [isTabDragging, setIsTabDragging] = useState(false); + const [splitDropTarget, setSplitDropTarget] = useState(null); + /** Whether the split pane visually renders before (true) or after (false) main. */ + const [splitLeading, setSplitLeading] = useState(false); + + // Auth bootstrap (mirrors standard page) + useEffect(() => { + const state = useAuthStore.getState(); + if (state.isAuthenticated && state.client) { + setInitialCheckDone(true); + return; + } + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { + redirectToLogin(); + } + }, [initialCheckDone, isAuthenticated, authLoading]); + + useEffect(() => { + if (initialCheckDone && (isMobile || isTablet) && typeof window !== "undefined") { + window.location.replace("/"); + } + }, [initialCheckDone, isMobile, isTablet]); + + const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]); + const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]); + + const focusedActiveTab = useMemo(() => { + const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId; + return tabs.find((t) => t.id === id) ?? null; + }, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]); + + const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => { + openTab(itemId); + return true; + }; + + const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null = + focusedActiveTab && ( + focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar' + || focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files' + || focusedActiveTab.kind === 'settings' + ) ? focusedActiveTab.kind : null; + + const isSplit = splitOrientation !== null && splitTabs.length > 0; + + // ---- Body-level drop targets ---- + + const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME); + + const computeDropTarget = (e: DragEvent): DropTarget => { + const rect = e.currentTarget.getBoundingClientRect(); + const xFrac = (e.clientX - rect.left) / rect.width; + return xFrac < 0.5 ? 'left' : 'right'; + }; + + const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => { + if (!target || !isSplit) return null; + const leftIsSplit = splitLeading; + if (target === 'left') return leftIsSplit ? 'split' : 'main'; + return leftIsSplit ? 'main' : 'split'; + }; + + const handleBodyDragOver = (e: DragEvent) => { + if (!isProTabDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + const next = computeDropTarget(e); + if (next !== splitDropTarget) setSplitDropTarget(next); + }; + + const handleBodyDragLeave = (e: DragEvent) => { + const next = e.relatedTarget as Node | null; + if (next && e.currentTarget.contains(next)) return; + setSplitDropTarget(null); + }; + + const handleBodyDrop = (e: DragEvent) => { + if (!isProTabDrag(e)) return; + const target = computeDropTarget(e); + setSplitDropTarget(null); + setIsTabDragging(false); + if (!target) return; + e.preventDefault(); + const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME); + if (!draggedId) return; + + if (isSplit) { + // Move tab to whichever pane occupies the dropped side. + const destPane = targetPaneFromDrop(target); + if (destPane) moveTabToPane(draggedId, destPane); + return; + } + // Create a new side-by-side split. `splitLeading` controls which side + // visually hosts the split pane. + moveTabToPane(draggedId, 'split', 'vertical'); + setSplitLeading(target === 'left'); + }; + + // Loading state (matches standard page exactly) + if (!initialCheckDone || authLoading || !isAuthenticated || !client) { + return ( +
+
+
+

{t("common.loading")}

+
+
+ ); + } + + if (!isDesktop) return null; + + // Stable keys are essential: when the split collapses, the row's child + // list goes from [splitPane, divider, mainPane] (or the leading variant) + // to [mainPane]. Without keys, React would reuse the Pane instance at + // index 0 — repurposing the *split* pane's instance into the main pane, + // which strands the main pane's ResizeObserver/paneWidth on a now- + // unmounted DOM node and reparents the mail tab body (causing remount + // + stale "still-narrow" measurements after the split is closed). + const mainPane = ( + + ); + + const splitPane = isSplit ? ( + + ) : null; + + const splitDivider = isSplit ? ( +