Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105194a8b9 | ||
|
|
8dbb538c98 | ||
|
|
e435356c53 | ||
|
|
6f9982540c | ||
|
|
d0d6632b24 | ||
|
|
4b7009dfc2 | ||
|
|
55a408e810 | ||
|
|
d5dddba6df | ||
|
|
d1a0667c79 | ||
|
|
e700e4fd04 | ||
|
|
cf993c1036 | ||
|
|
5fdf226ebe | ||
|
|
fae15f073e | ||
|
|
c646c87030 | ||
|
|
b4a76bc4d1 | ||
|
|
dfe886636b | ||
|
|
f499e87d2a | ||
|
|
32fe871b70 | ||
|
|
aab19379e2 | ||
|
|
b46a1a69e8 | ||
|
|
ea424cad7e | ||
|
|
3f444a8912 | ||
|
|
8b0e2052cf | ||
|
|
c99934a92c | ||
|
|
ce2731cd9d | ||
|
|
f9f8af2f11 | ||
|
|
d8e2a10806 | ||
|
|
869ee07ebc | ||
|
|
2ad2bb1e09 | ||
|
|
23bc31c661 | ||
|
|
a2f76037a1 | ||
|
|
9571f2e185 | ||
|
|
887b9c728c | ||
|
|
8c21f462c2 | ||
|
|
5f3d2d3e4a | ||
|
|
4bce80b8ba | ||
|
|
1d09f5a623 | ||
|
|
2c513129f2 | ||
|
|
b3dc2e32b8 | ||
|
|
b0640c9ecc | ||
|
|
2d7e24b513 | ||
|
|
5b30bacf10 | ||
|
|
fe937403f3 | ||
|
|
876ea370e4 | ||
|
|
1dcdeeae86 | ||
|
|
01302a775c | ||
|
|
76d78ae756 | ||
|
|
51745ea03d | ||
|
|
c44a9ce6e0 | ||
|
|
7fa65796f0 | ||
|
|
d09df7e8a3 |
+21
-4
@@ -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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -50,5 +50,3 @@ next-env.d.ts
|
||||
|
||||
# Sibling repos
|
||||
/repos/
|
||||
# benchmark
|
||||
benchmark/
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## 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 `<img>` 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
|
||||
|
||||
+26
-34
@@ -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 <div>{t("key")}</div>;
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+24
-13
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -37,6 +38,7 @@ 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";
|
||||
@@ -56,6 +58,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<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -68,9 +72,10 @@ 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 { 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 +101,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<ParsedWebcal | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
@@ -156,10 +165,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 +176,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 +1042,54 @@ export default function CalendarPage() {
|
||||
});
|
||||
}, [events, selectedCalendarIds, visibleEvents]);
|
||||
|
||||
if (!isAuthenticated || !supportsCalendar) return null;
|
||||
const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
|
||||
<ProtocolAccountPicker
|
||||
kind="webcal"
|
||||
operation={pendingWebcalAccountChoice}
|
||||
accounts={getWebcalProtocolAccounts()}
|
||||
activeAccountId={activeAccountId}
|
||||
isSwitching={isProtocolAccountSwitching}
|
||||
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
|
||||
onCancel={() => setPendingWebcalAccountChoice(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={tWebcalAction("title")}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
|
||||
const renderView = () => {
|
||||
if (isLoading && calendars.length === 0) {
|
||||
@@ -1378,14 +1512,23 @@ export default function CalendarPage() {
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
client={client}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
initialName={pendingSubscription?.name}
|
||||
onClose={() => {
|
||||
setShowSubscriptionModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1402,6 +1545,8 @@ export default function CalendarPage() {
|
||||
})()}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{renderWebcalAccountPicker()}
|
||||
{renderWebcalActionChoice()}
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -32,7 +33,9 @@ export default async function LocaleLayout({
|
||||
<RateLimitToastProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
{children}
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
{children}
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</RateLimitToastProvider>
|
||||
|
||||
+192
-51
@@ -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";
|
||||
@@ -53,12 +54,16 @@ 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 { 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";
|
||||
|
||||
@@ -73,6 +78,7 @@ export default function Home() {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
@@ -88,8 +94,10 @@ export default function Home() {
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(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);
|
||||
@@ -202,7 +210,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,
|
||||
@@ -307,6 +315,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).
|
||||
@@ -636,6 +651,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 +665,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<typeof setTimeout> | null = null;
|
||||
@@ -657,18 +744,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 +759,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 +775,33 @@ export default function Home() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
client.closePushNotifications();
|
||||
};
|
||||
}
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
|
||||
|
||||
// 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, 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 +953,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 +964,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
|
||||
@@ -1641,9 +1720,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;
|
||||
|
||||
@@ -1661,14 +1761,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
|
||||
@@ -1697,9 +1798,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 }) => {
|
||||
@@ -1958,21 +2061,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
|
||||
@@ -2281,7 +2393,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)}
|
||||
@@ -2289,17 +2401,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 */}
|
||||
@@ -2331,7 +2455,13 @@ export default function Home() {
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
onSaveState={(data) => setPendingDraft(data)}
|
||||
onSaveState={(data) => {
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
@@ -2486,6 +2616,17 @@ export default function Home() {
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{pendingMailtoAccountChoice && (
|
||||
<ProtocolAccountPicker
|
||||
kind="mailto"
|
||||
operation={pendingMailtoAccountChoice}
|
||||
accounts={getMailtoProtocolAccounts()}
|
||||
activeAccountId={activeAccountId}
|
||||
isSwitching={isProtocolAccountSwitching}
|
||||
onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
|
||||
onCancel={() => setPendingMailtoAccountChoice(null)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<PromptDialog {...promptDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
@@ -63,6 +64,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
@@ -98,6 +100,7 @@ type Tab =
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'protocol_handlers'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
@@ -133,6 +136,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
@@ -192,6 +196,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'],
|
||||
@@ -210,6 +215,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
calendar: ['calendar.settings', 'calendar.management'],
|
||||
contacts: ['settings.contacts', 'contacts'],
|
||||
files: ['settings.files'],
|
||||
protocol_handlers: ['protocol_handlers'],
|
||||
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
|
||||
about_data: ['settings.advanced'],
|
||||
themes: [],
|
||||
@@ -239,6 +245,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
files: 'attachments cloud drive storage upload',
|
||||
protocol_handlers: 'mailto webcal links default app protocol handler',
|
||||
sidebar_apps: 'apps webview iframe',
|
||||
about_data: 'export import storage quota privacy backup',
|
||||
themes: 'custom theme css skin appearance',
|
||||
@@ -559,6 +566,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
@@ -582,7 +590,7 @@ export default function SettingsPage() {
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
|
||||
// Advanced
|
||||
@@ -665,6 +673,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+36
-24
@@ -27,11 +27,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
@@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
@@ -177,6 +179,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// /admin lives outside the [locale] tree, so links back to the webmail
|
||||
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
|
||||
// basePath to <Link>/router APIs - for these we prepend it manually so
|
||||
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
|
||||
const prefix = getPathPrefix();
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
@@ -274,39 +282,41 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href="/"
|
||||
href={`${prefix}/`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
href={`${prefix}/settings`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
@@ -411,7 +421,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
href={`${prefix}/`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Mail"
|
||||
>
|
||||
@@ -419,7 +429,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Calendar"
|
||||
>
|
||||
@@ -427,21 +437,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
@@ -454,7 +466,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
href={`${prefix}/settings`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
{logoUrl ? (
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -15,10 +20,12 @@ 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(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -74,10 +81,16 @@ export async function POST(request: NextRequest) {
|
||||
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);
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -57,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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
|
||||
// =====================================================================
|
||||
{
|
||||
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Willkommen bei Bulwark Webmail!',
|
||||
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
|
||||
preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: {
|
||||
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
|
||||
p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -197,7 +197,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
|
||||
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Sprint planning - next week priorities',
|
||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||
hasAttachment: false,
|
||||
@@ -367,7 +367,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
@@ -471,7 +471,7 @@ const emails: MockEmail[] = [
|
||||
{
|
||||
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
|
||||
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
|
||||
subject: 'Design review feedback',
|
||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||
hasAttachment: false,
|
||||
@@ -485,7 +485,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||
hasAttachment: false,
|
||||
@@ -639,7 +639,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Conference talk accepted!',
|
||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||
@@ -728,8 +728,8 @@ const IDENTITIES = [
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
@@ -743,6 +743,12 @@ const addressBooks = [
|
||||
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
|
||||
];
|
||||
|
||||
// Profile photos served straight from randomuser.me's CDN; the API at
|
||||
// https://randomuser.me/api/ also returns these portrait URLs, but for a
|
||||
// fixed mock dataset we link them directly to keep things offline-friendly.
|
||||
// See https://randomuser.me/documentation#howto
|
||||
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||
|
||||
const contacts = [
|
||||
// --- Personal address book ---
|
||||
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
@@ -752,6 +758,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'EuroTech GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
|
||||
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||
@@ -760,6 +767,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||
@@ -768,6 +776,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||
@@ -775,6 +784,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+31 20 555 0142' } },
|
||||
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
|
||||
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||
@@ -783,6 +793,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
|
||||
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||
@@ -791,6 +802,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
|
||||
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||
@@ -798,6 +810,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
|
||||
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||
@@ -806,6 +819,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'BergLabs' } },
|
||||
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
|
||||
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||
@@ -814,6 +828,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
|
||||
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||
@@ -822,6 +837,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
|
||||
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Work address book ---
|
||||
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
@@ -831,6 +847,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
||||
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||
@@ -839,6 +856,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
|
||||
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||
@@ -847,6 +865,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
|
||||
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
||||
@@ -855,6 +874,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'García Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
|
||||
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||
@@ -863,6 +883,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Digitaal BV' } },
|
||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||
@@ -871,6 +892,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
|
||||
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||
@@ -879,6 +901,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
|
||||
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||
@@ -887,6 +910,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Ferreira Media' } },
|
||||
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
|
||||
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||
@@ -895,6 +919,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
|
||||
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
||||
@@ -904,6 +929,7 @@ const contacts = [
|
||||
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
||||
nicknames: { n1: { name: 'Anni' } },
|
||||
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Groups ---
|
||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||
@@ -976,7 +1002,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
},
|
||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||
@@ -986,7 +1012,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
},
|
||||
@@ -1024,7 +1050,7 @@ const calendarEvents = [
|
||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
},
|
||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||
@@ -1054,7 +1080,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
@@ -1066,7 +1092,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
|
||||
@@ -1084,7 +1110,7 @@ const calendarEvents = [
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
p5: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p5: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
|
||||
@@ -1093,7 +1119,7 @@ const calendarEvents = [
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p4: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p4: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
|
||||
@@ -1109,7 +1135,7 @@ const calendarEvents = [
|
||||
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
|
||||
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
|
||||
participants: {
|
||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
||||
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
@@ -1192,7 +1218,7 @@ const calendarEvents = [
|
||||
}),
|
||||
|
||||
// ===== Birthday calendar (cal-5) =====
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
|
||||
showWithoutTime: true,
|
||||
recurrence: [{ frequency: 'yearly' }],
|
||||
description: 'Don\'t forget to bring Kuchen!',
|
||||
@@ -1220,7 +1246,7 @@ const calendarEvents = [
|
||||
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let authHeader: string | null = null;
|
||||
if (parsed.username || parsed.password) {
|
||||
const username = decodeURIComponent(parsed.username);
|
||||
const password = decodeURIComponent(parsed.password);
|
||||
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
}
|
||||
|
||||
return { cleanUrl: parsed.toString(), authHeader };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await isPublicHttpUrl(url))) {
|
||||
const extracted = extractBasicAuth(url);
|
||||
if (!extracted) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { cleanUrl, authHeader } = extracted;
|
||||
|
||||
if (!(await isPublicHttpUrl(cleanUrl))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let currentUrl = cleanUrl;
|
||||
const originalOrigin = new URL(cleanUrl).origin;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
@@ -36,12 +64,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
};
|
||||
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 } {
|
||||
@@ -50,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. */
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/setup/branding - wizard branding upload.
|
||||
*
|
||||
* Multipart form fields:
|
||||
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||
*
|
||||
* Mirrors /api/admin/branding but authenticates via the wizard cookie
|
||||
* instead of admin session - admin auth doesn't exist yet during bootstrap.
|
||||
* Files land in the same directory; the public read endpoint at
|
||||
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
||||
* assets after setup.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
if (!(await authenticateWizardRequest())) {
|
||||
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertWritable('upload branding asset');
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
const slot = formData.get('slot');
|
||||
|
||||
if (!(file instanceof File) || typeof slot !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
|
||||
const dir = getBrandingDir();
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove any existing file for this slot with a different extension so
|
||||
// the wizard doesn't leave orphan files behind on re-upload.
|
||||
for (const otherExt of Object.values(EXT_BY_MIME)) {
|
||||
if (otherExt === ext) continue;
|
||||
const oldPath = path.join(dir, `${slot}${otherExt}`);
|
||||
if (existsSync(oldPath)) {
|
||||
try { await unlink(oldPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filePath = path.join(dir, safeName);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
logger.error('Wizard branding upload failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/setup/branding - remove an uploaded asset and clear the
|
||||
* config override so the slot falls back to the system default.
|
||||
*
|
||||
* Body: { slot: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
if (!(await authenticateWizardRequest())) {
|
||||
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertWritable('remove branding asset');
|
||||
const { slot } = (await request.json()) as { slot?: string };
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getBrandingDir();
|
||||
for (const ext of Object.values(EXT_BY_MIME)) {
|
||||
const filePath = path.join(dir, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
try { await unlink(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Wizard branding delete failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { setInitialAdminPassword } from '@/lib/admin/password';
|
||||
import { clearSetupToken } from '@/lib/setup/token';
|
||||
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/setup/finish
|
||||
*
|
||||
* Final wizard step. Validates that required config is in place, hashes the
|
||||
* admin password, marks setup complete, deletes the setup token (which
|
||||
* invalidates the wizard cookie), and optionally drops a `.config-locked`
|
||||
* marker so the operator remembers they intended to mount :ro.
|
||||
*
|
||||
* Body: { adminPassword: string, lockConfig?: boolean }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
if (!(await authenticateWizardRequest())) {
|
||||
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { adminPassword?: unknown; lockConfig?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminPassword =
|
||||
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
|
||||
if (adminPassword.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin password must be at least 8 characters' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const lockConfig = body?.lockConfig === true;
|
||||
|
||||
// Validate required config is present.
|
||||
await configManager.ensureLoaded();
|
||||
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
|
||||
if (!jmapUrl || typeof jmapUrl !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'JMAP server URL is required (run the Server step first)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Provision the admin account. Aborts cleanly if one already exists
|
||||
// (defence in depth - should be impossible in bootstrap state).
|
||||
const created = await setInitialAdminPassword(adminPassword);
|
||||
if (!created) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin account already exists; cannot finish setup again' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Persist setupComplete flag. After this, detectSetupState() flips
|
||||
// to 'configured' and middleware starts 404'ing /setup paths.
|
||||
await configManager.markSetupComplete();
|
||||
|
||||
// 3. Optional advisory lock marker.
|
||||
if (lockConfig) {
|
||||
await ensureConfigDir();
|
||||
await writeFile(
|
||||
getConfigPath('.config-locked'),
|
||||
new Date().toISOString(),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Destroy the setup token. Any other browser holding the cookie is
|
||||
// now unauthenticated.
|
||||
await clearSetupToken();
|
||||
|
||||
await auditLog(
|
||||
'setup.finish',
|
||||
{ lockConfig, jmapServerUrl: jmapUrl },
|
||||
request.headers.get('x-forwarded-for') ?? 'unknown',
|
||||
);
|
||||
|
||||
const response = NextResponse.json({ ok: true, lockConfig });
|
||||
response.cookies.delete(SETUP_COOKIE);
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error('Wizard finish failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isConfigReadOnly } from '@/lib/admin/paths';
|
||||
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/setup/status - public endpoint that returns the wizard state
|
||||
* and (if authenticated) the partial config saved by previous steps. The
|
||||
* wizard polls this on load so a refresh resumes with prior values.
|
||||
*
|
||||
* Sensitive values (OAuth client secret, session secret) are NEVER sent
|
||||
* back to the client - only a `<key>HasValue` boolean. Re-entering them
|
||||
* after refresh is the price of not exposing them.
|
||||
*/
|
||||
export async function GET() {
|
||||
await configManager.ensureLoaded();
|
||||
const state = detectSetupState();
|
||||
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
|
||||
|
||||
let partialConfig: Record<string, unknown> | null = null;
|
||||
if (state === 'bootstrap' && authenticated) {
|
||||
// Only echo back values the operator has actually saved during the
|
||||
// wizard (admin overrides). System defaults must not flow back here,
|
||||
// because the wizard has its own opinionated defaults (e.g. settings
|
||||
// sync on by default) that we'd otherwise stomp.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const [key, info] of Object.entries(sources)) {
|
||||
if (info.source !== 'admin') continue;
|
||||
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
|
||||
} else {
|
||||
safe[key] = info.value;
|
||||
}
|
||||
}
|
||||
partialConfig = safe;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
state,
|
||||
authenticated,
|
||||
readOnly: isConfigReadOnly(),
|
||||
partialConfig,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Mapping of wizard-friendly step keys to the config keys they update. Each
|
||||
* step's PATCH validates against this allowlist so a compromised wizard
|
||||
* client can't slip in arbitrary config keys.
|
||||
*/
|
||||
const STEP_KEYS: Record<string, string[]> = {
|
||||
server: [
|
||||
'appName',
|
||||
'jmapServerUrl',
|
||||
'stalwartFeaturesEnabled',
|
||||
'jmapServers',
|
||||
'jmapServerAutoPickByDomain',
|
||||
],
|
||||
auth: [
|
||||
'oauthEnabled',
|
||||
'oauthOnly',
|
||||
'oauthClientId',
|
||||
'oauthClientSecret',
|
||||
'oauthIssuerUrl',
|
||||
],
|
||||
security: ['sessionSecret', 'settingsSyncEnabled'],
|
||||
logging: ['logFormat', 'logLevel'],
|
||||
branding: [
|
||||
'faviconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'loginCompanyName',
|
||||
'loginImprintUrl',
|
||||
'loginPrivacyPolicyUrl',
|
||||
'loginWebsiteUrl',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/setup/step
|
||||
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
|
||||
*
|
||||
* Persists partial config under the admin override (config.json). Each
|
||||
* step's allowed keys are restricted by STEP_KEYS so the client can only
|
||||
* touch what the corresponding screen owns.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
if (!(await authenticateWizardRequest())) {
|
||||
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { step?: unknown; values?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const step = typeof body?.step === 'string' ? body.step : '';
|
||||
const values = body?.values;
|
||||
const allowedKeys = STEP_KEYS[step];
|
||||
if (!allowedKeys) {
|
||||
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
|
||||
}
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
||||
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
||||
}
|
||||
if (!(key in CONFIG_ENV_MAP)) {
|
||||
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||
}
|
||||
if (key === 'jmapServers') {
|
||||
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
|
||||
// before they're persisted. Mirrors the admin config PATCH route.
|
||||
if (value != null && !Array.isArray(value)) {
|
||||
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseJmapServers(value);
|
||||
const incomingCount = Array.isArray(value) ? value.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json(
|
||||
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
updates[key] = sanitized;
|
||||
continue;
|
||||
}
|
||||
updates[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig(updates);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Wizard step save failed', {
|
||||
step,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
|
||||
* the check_jmap_server() helper in setup.sh: we hit a few common session
|
||||
* endpoints and look for capability strings to confirm the URL is actually
|
||||
* a JMAP server (vs. a generic HTTP 200 page).
|
||||
*
|
||||
* Body: { url: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
if (!(await authenticateWizardRequest())) {
|
||||
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { url?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
|
||||
if (!raw) {
|
||||
return NextResponse.json({ error: 'url required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
|
||||
}
|
||||
|
||||
const base = raw.replace(/\/+$/, '');
|
||||
|
||||
for (const endpoint of JMAP_ENDPOINTS) {
|
||||
const target = base + endpoint;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(target, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) continue;
|
||||
const text = await res.text();
|
||||
if (looksLikeJmapSession(text)) {
|
||||
return NextResponse.json({
|
||||
status: 'jmap_detected',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Try the next endpoint; we'll fall through to a final reachability
|
||||
// check below if none match.
|
||||
}
|
||||
}
|
||||
|
||||
// No JMAP session found. Was the server even reachable?
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(base, {
|
||||
method: 'HEAD',
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return NextResponse.json({
|
||||
status: 'reachable_no_jmap',
|
||||
httpStatus: res.status,
|
||||
message:
|
||||
'Server responded but no JMAP session was found at standard paths. ' +
|
||||
'This is OK if a reverse proxy routes JMAP separately.',
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
status: 'unreachable',
|
||||
message: error instanceof Error ? error.message : 'Connection failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeJmapSession(body: string): boolean {
|
||||
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { detectSetupState } from '@/lib/setup/state';
|
||||
import { verifySetupToken } from '@/lib/setup/token';
|
||||
import { buildSessionCookieAttributes } from '@/lib/setup/session';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
|
||||
* startup) for a wizard session cookie. After this, subsequent step calls
|
||||
* authenticate via the cookie instead of pasting the token every time.
|
||||
*
|
||||
* Body: { token: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (detectSetupState() !== 'bootstrap') {
|
||||
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: { token?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
|
||||
if (!submitted) {
|
||||
return NextResponse.json({ error: 'Token required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await verifySetupToken(submitted);
|
||||
if (!ok) {
|
||||
// Don't differentiate between "wrong token" and "no token issued" - the
|
||||
// operator either has it from the logs or they don't.
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const attrs = buildSessionCookieAttributes();
|
||||
response.cookies.set(attrs.name, submitted, {
|
||||
httpOnly: attrs.httpOnly,
|
||||
sameSite: attrs.sameSite,
|
||||
secure: attrs.secure,
|
||||
path: attrs.path,
|
||||
maxAge: attrs.maxAge,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
+8
-1
@@ -171,8 +171,15 @@ body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
"Noto Sans Thai",
|
||||
"Leelawadee UI",
|
||||
Tahoma,
|
||||
sans-serif;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||
<defs>
|
||||
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||
<style>
|
||||
.icon-bg { fill: #ffffff; }
|
||||
.icon-mark { fill: rgb(219,45,84); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-bg { fill: #18181b; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||
<rect width="1000" height="1000" class="icon-bg"/>
|
||||
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
+4
-2
@@ -4,6 +4,7 @@ import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -17,7 +18,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const faviconUrl = process.env.FAVICON_URL;
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
@@ -30,7 +32,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
icons: { icon: faviconUrl },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -2,13 +2,26 @@ import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type WebAppProtocolHandler = {
|
||||
protocol: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ExtendedManifest = MetadataRoute.Manifest & {
|
||||
protocol_handlers?: WebAppProtocolHandler[];
|
||||
launch_handler?: {
|
||||
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
|
||||
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
|
||||
};
|
||||
};
|
||||
|
||||
// Manifest paths must include the deployment subpath - browsers resolve them
|
||||
// against the document origin, not the manifest's location, and Next.js does
|
||||
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
export default function manifest(): ExtendedManifest {
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
@@ -57,5 +70,12 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
protocol_handlers: [
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
],
|
||||
launch_handler: {
|
||||
client_mode: ["focus-existing", "navigate-new"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
|
||||
|
||||
export default async function MailtoProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
|
||||
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
|
||||
|
||||
export default async function WebcalProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
|
||||
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
|
||||
}
|
||||
+1736
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
type ImportMode = "file" | "url";
|
||||
|
||||
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
|
||||
const t = useTranslations("calendar.import");
|
||||
const tCal = useTranslations("calendar");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("file");
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
|
||||
const [urlInput, setUrlInput] = useState(initialUrl || "");
|
||||
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
editSubscription?: ICalSubscription;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
|
||||
const t = useTranslations("calendar.subscription");
|
||||
const tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
|
||||
|
||||
const isEdit = !!editSubscription;
|
||||
|
||||
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||
const [name, setName] = useState(editSubscription?.name || "");
|
||||
const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
|
||||
const [name, setName] = useState(editSubscription?.name || initialName || "");
|
||||
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -32,9 +32,10 @@ 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";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
@@ -55,6 +56,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 +74,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[];
|
||||
@@ -111,6 +117,39 @@ type ComposerAttachment = {
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
type SignatureIdentityLike = {
|
||||
htmlSignature?: string;
|
||||
textSignature?: string;
|
||||
} | null | undefined;
|
||||
|
||||
// Render the embedded signature for "above quote" mode. Bracketed with
|
||||
// `data-signature-block` marker paragraphs so we can swap the inner content
|
||||
// when the user switches identity without losing the surrounding draft or
|
||||
// quoted message. The markers are preserved through TipTap by the
|
||||
// StyledParagraph extension.
|
||||
function buildEmbeddedSignatureHtml(
|
||||
identity: SignatureIdentityLike,
|
||||
options: { embed: boolean; separator: boolean }
|
||||
): string {
|
||||
if (!options.embed) return '';
|
||||
const startMarker = options.separator
|
||||
? `<p data-signature-block="separator">-- </p>`
|
||||
: `<p data-signature-block="start"></p>`;
|
||||
const endMarker = `<p data-signature-block="end"></p>`;
|
||||
if (identity?.htmlSignature) {
|
||||
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature) {
|
||||
const escaped = identity.textSignature
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${startMarker}<p>${escaped}</p>${endMarker}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
@@ -130,6 +169,25 @@ 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 signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
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 +237,19 @@ 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 plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
const signatureBlock = shouldEmbedSignatureAboveQuote
|
||||
? `${plainSep}${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 +261,25 @@ export function EmailComposer({
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>').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 +317,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 +351,96 @@ 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;
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
|
||||
const currentHtml = editor.getHTML();
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||
if (!startEl) return;
|
||||
const endEl = doc.querySelector('[data-signature-block="end"]');
|
||||
|
||||
const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
if (!newSignature) return;
|
||||
|
||||
// Build a temporary container holding the replacement nodes so we can
|
||||
// splice them in without re-serializing/parsing twice.
|
||||
const replacementHost = doc.createElement('div');
|
||||
replacementHost.innerHTML = newSignature;
|
||||
const replacementNodes = Array.from(replacementHost.childNodes);
|
||||
|
||||
const parent = startEl.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
||||
// from startEl to the next blockquote if no end marker is present.
|
||||
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
|
||||
const toRemove: Node[] = [];
|
||||
let cursor: Node | null = startEl;
|
||||
while (cursor) {
|
||||
toRemove.push(cursor);
|
||||
if (cursor === removeUntil) break;
|
||||
const next: Node | null = cursor.nextSibling;
|
||||
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
|
||||
cursor = next;
|
||||
}
|
||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||
toRemove.forEach((node) => parent.removeChild(node));
|
||||
replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
||||
|
||||
const nextHtml = doc.body.innerHTML;
|
||||
if (nextHtml !== currentHtml) {
|
||||
editor.commands.setContent(nextHtml, { emitUpdate: true });
|
||||
}
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
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 +459,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
fromOverrideEnabled,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
@@ -322,10 +470,10 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
@@ -361,8 +509,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 +903,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 +926,7 @@ export function EmailComposer({
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
uploadedAttachments,
|
||||
currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
plainTextMode ? undefined : body
|
||||
);
|
||||
|
||||
@@ -945,20 +1099,39 @@ 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 '';
|
||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (currentIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
if (signatureIdentity?.textSignature) {
|
||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -969,9 +1142,10 @@ export function EmailComposer({
|
||||
: null;
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const finalBody = plainTextMode
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
@@ -1006,6 +1180,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 +1330,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 +1360,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 +1371,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 +1381,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 +1393,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 +1477,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,16 +1527,18 @@ export function EmailComposer({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
{subAddressTag && (
|
||||
{!fromOverrideEnabled && (
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
)}
|
||||
{!fromOverrideEnabled && subAddressTag && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1349,6 +1550,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>
|
||||
|
||||
@@ -1495,20 +1718,24 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
/>
|
||||
</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)}
|
||||
{signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
|
||||
</div>
|
||||
) : null
|
||||
) : composerSignatureHtml ? (
|
||||
<div
|
||||
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||
dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -129,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 && (
|
||||
@@ -161,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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -2296,7 +2341,7 @@ export function EmailViewer({
|
||||
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
|
||||
// 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') {
|
||||
@@ -2691,7 +2736,7 @@ export function EmailViewer({
|
||||
// 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 —
|
||||
// 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 ? `
|
||||
@@ -2888,7 +2933,7 @@ 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 —
|
||||
// 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;
|
||||
@@ -4404,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
|
||||
@@ -4414,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">
|
||||
@@ -4457,6 +4507,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
{effectiveAttachments.length > 2 && (
|
||||
@@ -4477,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]">
|
||||
@@ -4509,6 +4566,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -4762,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
|
||||
@@ -4772,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">
|
||||
@@ -4820,6 +4884,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||
@@ -4840,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]">
|
||||
@@ -4872,6 +4943,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -4891,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
|
||||
@@ -4901,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">
|
||||
@@ -4944,6 +5022,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
{effectiveAttachments.length > 2 && (
|
||||
@@ -4963,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]">
|
||||
@@ -4995,6 +5080,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
// Pasted email content (signatures, replies, quoted text) commonly carries
|
||||
// inline styles on block elements. StarterKit's default Paragraph/Heading
|
||||
// drop unknown attributes; extend them to round-trip `style` and `class` so
|
||||
// signature formatting survives the editor.
|
||||
const styledBlockAttributes = {
|
||||
style: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.style ? { style: attrs.style } : {},
|
||||
},
|
||||
class: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.class ? { class: attrs.class } : {},
|
||||
},
|
||||
"data-signature-block": {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs["data-signature-block"]
|
||||
? { "data-signature-block": attrs["data-signature-block"] }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
|
||||
const StyledParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const StyledHeading = Heading.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
@@ -131,17 +179,23 @@ export function RichTextEditor({
|
||||
placeholder,
|
||||
className,
|
||||
hasError,
|
||||
onEditorReady,
|
||||
}: RichTextEditorProps) {
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
StyledHeading.configure({ levels: [1, 2] }),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
@@ -239,6 +293,12 @@ export function RichTextEditor({
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
// Expose the editor instance once it's ready so parents can target
|
||||
// specific nodes (e.g. swap the embedded signature on identity change).
|
||||
useEffect(() => {
|
||||
if (editor) onEditorReadyRef.current?.(editor);
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href;
|
||||
|
||||
@@ -331,7 +331,7 @@ 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.
|
||||
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
|
||||
// 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;
|
||||
|
||||
@@ -149,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 && (
|
||||
@@ -179,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}
|
||||
/>
|
||||
@@ -508,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 && (
|
||||
@@ -564,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}
|
||||
/>
|
||||
|
||||
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-html-sig"
|
||||
maxLength={5000}
|
||||
maxLength={50000}
|
||||
value={formData.htmlSignature}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
|
||||
rows={5}
|
||||
|
||||
@@ -6,9 +6,10 @@ 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, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
|
||||
}
|
||||
|
||||
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={account.label}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +48,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 +113,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 (
|
||||
<>
|
||||
|
||||
@@ -17,11 +17,12 @@ 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, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { 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";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -610,7 +611,6 @@ export function NavigationRail({
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
@@ -618,15 +618,20 @@ export function NavigationRail({
|
||||
if (!isActive) switchAccount(account.id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||
"relative w-8 h-8 rounded-full transition-all flex-shrink-0",
|
||||
isActive
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||
>
|
||||
{initials}
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-2 h-2 text-primary-foreground" />
|
||||
|
||||
@@ -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 (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
||||
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
||||
else return;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
User,
|
||||
Users,
|
||||
Palmtree,
|
||||
Settings,
|
||||
X,
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
NotebookPen,
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -88,6 +93,11 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
|
||||
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
|
||||
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
|
||||
if (role === "archive" || lowerName.includes("archive")) return Archive;
|
||||
if (role === "shared" || lowerName.includes("shared")) return Users;
|
||||
if (role === "important" || lowerName.includes("important")) return AlertTriangle;
|
||||
if (role === "memos" || lowerName.includes("memo")) return NotebookPen;
|
||||
if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock;
|
||||
if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff;
|
||||
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
|
||||
|
||||
if (hasChildren) {
|
||||
@@ -104,6 +114,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
|
||||
trash: "text-muted-foreground",
|
||||
junk: "text-red-600/80 dark:text-red-400/80",
|
||||
archive: "text-amber-600/80 dark:text-amber-400/80",
|
||||
shared: "text-cyan-600/80 dark:text-cyan-400/80",
|
||||
important: "text-orange-600/80 dark:text-orange-400/80",
|
||||
memos: "text-yellow-600/80 dark:text-yellow-400/80",
|
||||
scheduled: "text-sky-600/80 dark:text-sky-400/80",
|
||||
snoozed: "text-slate-500/80 dark:text-slate-400/80",
|
||||
};
|
||||
|
||||
function resolveRoleKey(role?: string, name?: string): string | undefined {
|
||||
@@ -114,6 +129,11 @@ function resolveRoleKey(role?: string, name?: string): string | undefined {
|
||||
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
|
||||
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
|
||||
if (role === "archive" || lowerName.includes("archive")) return "archive";
|
||||
if (role === "shared" || lowerName.includes("shared")) return "shared";
|
||||
if (role === "important" || lowerName.includes("important")) return "important";
|
||||
if (role === "memos" || lowerName.includes("memo")) return "memos";
|
||||
if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled";
|
||||
if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/mailto";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
return index > 0 ? window.location.pathname.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function returnToSourcePage() {
|
||||
window.close();
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openFallbackAppTab(raw: string): boolean {
|
||||
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
}
|
||||
|
||||
async function focusExistingClient() {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
worker?.postMessage({ type: "focus-existing-mailto-client" });
|
||||
} catch {
|
||||
// Focusing is a progressive enhancement; the composer handoff still works.
|
||||
}
|
||||
}
|
||||
|
||||
interface MailtoProtocolClientProps {
|
||||
openingText: string;
|
||||
}
|
||||
|
||||
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function handleMailto() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
const isFallbackAppTab = params.get("fallback") === "1";
|
||||
const openMode = useSettingsStore.getState().protocolOpenMode;
|
||||
const parsed = raw ? parseMailto(raw) : null;
|
||||
|
||||
if (parsed) {
|
||||
if (!isFallbackAppTab && openMode === "new-tab") {
|
||||
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
} else if (!isFallbackAppTab) {
|
||||
const delivered = await requestOpenMailtoInExistingClient(parsed);
|
||||
if (cancelled) return;
|
||||
|
||||
if (delivered) {
|
||||
void focusExistingClient();
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
savePendingMailto(parsed);
|
||||
}
|
||||
|
||||
window.location.replace(`${getProtocolPathPrefix()}/`);
|
||||
}
|
||||
|
||||
void handleMailto();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import type { AccountEntry } from "@/stores/account-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
type ProtocolAccountPickerProps = {
|
||||
accounts: AccountEntry[];
|
||||
activeAccountId: string | null;
|
||||
isSwitching?: boolean;
|
||||
onSelect: (accountId: string) => void;
|
||||
onCancel: () => void;
|
||||
} & (
|
||||
| { kind: "mailto"; operation?: ParsedMailto }
|
||||
| { kind: "webcal"; operation?: ParsedWebcal }
|
||||
);
|
||||
|
||||
function getHost(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProtocolAccountPicker({
|
||||
kind,
|
||||
accounts,
|
||||
activeAccountId,
|
||||
isSwitching = false,
|
||||
onSelect,
|
||||
onCancel,
|
||||
operation,
|
||||
}: ProtocolAccountPickerProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const tCommon = useTranslations("common");
|
||||
const details = operation
|
||||
? kind === "mailto"
|
||||
? [
|
||||
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
|
||||
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
|
||||
]
|
||||
: [
|
||||
{ label: t("detail_calendar"), value: operation.suggestedName },
|
||||
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("select_account_title")}
|
||||
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{details.length > 0 && (
|
||||
<div className="border-b border-border bg-muted/40 px-5 py-3">
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
|
||||
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-80 overflow-y-auto p-2">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
let host = account.serverUrl;
|
||||
try {
|
||||
host = new URL(account.serverUrl).hostname;
|
||||
} catch {
|
||||
// Keep the configured value when it is not an absolute URL.
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
type="button"
|
||||
disabled={isSwitching}
|
||||
onClick={() => onSelect(account.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
isSwitching && "cursor-wait opacity-70"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{account.displayName || account.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
{t("active_account")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
||||
{isSwitching ? (
|
||||
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("switching_account")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSwitching}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import {
|
||||
listenForMailtoRequests,
|
||||
notifyPendingMailto,
|
||||
notifyPendingWebcal,
|
||||
requestOpenMailtoInExistingClient,
|
||||
savePendingMailto,
|
||||
savePendingWebcal,
|
||||
} from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type LaunchParams = { targetURL?: string };
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
launchQueue?: {
|
||||
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getProtocolLaunch(targetURL: string):
|
||||
| { kind: "mailto"; raw: string }
|
||||
| { kind: "webcal"; raw: string }
|
||||
| null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(targetURL, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
const raw = url.searchParams.get("url");
|
||||
if (!raw) return null;
|
||||
|
||||
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
|
||||
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
}
|
||||
|
||||
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
|
||||
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProtocolLaunchHandlerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/protocol/")) return;
|
||||
|
||||
return listenForMailtoRequests((pending) => {
|
||||
savePendingMailto(pending);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
}, () => ({
|
||||
path: pathname,
|
||||
standalone: isStandaloneDisplayMode(),
|
||||
focusNotificationTitle: t("focus_notification_title"),
|
||||
focusNotificationBody: t("focus_notification_body"),
|
||||
}));
|
||||
}, [pathname, router, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.launchQueue) return;
|
||||
|
||||
window.launchQueue.setConsumer((launchParams) => {
|
||||
if (!launchParams.targetURL) return;
|
||||
|
||||
const launch = getProtocolLaunch(launchParams.targetURL);
|
||||
if (!launch) return;
|
||||
|
||||
if (launch.kind === "mailto") {
|
||||
const parsed = parseMailto(launch.raw);
|
||||
if (!parsed) return;
|
||||
|
||||
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
|
||||
if (openProtocolInNewTab("mailto", launch.raw)) return;
|
||||
savePendingMailto(parsed);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
|
||||
if (delivered) return;
|
||||
savePendingMailto(parsed);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseWebcal(launch.raw);
|
||||
if (!parsed) return;
|
||||
|
||||
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
|
||||
if (openProtocolInNewTab("webcal", launch.raw)) return;
|
||||
}
|
||||
|
||||
savePendingWebcal(parsed);
|
||||
notifyPendingWebcal();
|
||||
if (pathname !== "/calendar") router.push("/calendar");
|
||||
});
|
||||
}, [pathname, router]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/webcal";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
return index > 0 ? window.location.pathname.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function returnToSourcePage() {
|
||||
window.close();
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openFallbackAppTab(raw: string): boolean {
|
||||
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
}
|
||||
|
||||
interface WebcalProtocolClientProps {
|
||||
openingText: string;
|
||||
}
|
||||
|
||||
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
const isFallbackAppTab = params.get("fallback") === "1";
|
||||
|
||||
if (raw) {
|
||||
const parsed = parseWebcal(raw);
|
||||
if (parsed) {
|
||||
if (!isFallbackAppTab
|
||||
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
|
||||
&& shouldOpenFallbackAppTab()
|
||||
&& openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
|
||||
savePendingWebcal(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import daMessages from '@/locales/da/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
import koMessages from '@/locales/ko/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import lvMessages from '@/locales/lv/common.json';
|
||||
import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
@@ -23,13 +24,14 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
da: daMessages,
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
es: esMessages,
|
||||
fr: frMessages,
|
||||
it: itMessages,
|
||||
ja: jaMessages,
|
||||
ko: koMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
lv: lvMessages,
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
|
||||
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
initializeTheme();
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault();
|
||||
const { resolvedTheme, setTheme } = useThemeStore.getState();
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
|
||||
{tTour('restart_button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={tTour('show_on_new_devices_title')} description={tTour('show_on_new_devices_desc')}>
|
||||
<ToggleSwitch
|
||||
checked={showOnboardingOnNewDevices}
|
||||
onChange={(checked) => updateSetting('showOnboardingOnNewDevices', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
isSupportedSubAddressDelimiter,
|
||||
@@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
@@ -27,20 +23,11 @@ export function ComposingSettings() {
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
@@ -50,6 +37,24 @@ 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('signature_separator.label')} description={t('signature_separator.description')}>
|
||||
<ToggleSwitch
|
||||
checked={signatureSeparatorEnabled}
|
||||
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
@@ -148,24 +153,6 @@ export function ComposingSettings() {
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
|
||||
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
|
||||
AlertTriangle, NotebookPen, CalendarClock, BellOff,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
|
||||
trash: Trash,
|
||||
junk: ShieldAlert,
|
||||
archive: Archive,
|
||||
shared: Users,
|
||||
important: AlertTriangle,
|
||||
memos: NotebookPen,
|
||||
scheduled: CalendarClock,
|
||||
snoozed: BellOff,
|
||||
};
|
||||
|
||||
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
|
||||
|
||||
@@ -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,36 @@ 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">
|
||||
)}
|
||||
|
||||
{value === 'focus' && (
|
||||
<div className="flex-1 bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS_FOCUS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
<span className="font-medium">{row.sender}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value === 'horizontal' && (
|
||||
<div className="flex-1 flex flex-col bg-background">
|
||||
<div className="border-b border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<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 +99,11 @@ function MailLayoutPreview({
|
||||
</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} />
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { ProtocolOpenMode } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection, SettingItem, Select } from "./settings-section";
|
||||
|
||||
type Protocol = "mailto" | "webcal";
|
||||
|
||||
function canRegisterProtocolHandler(): boolean {
|
||||
return typeof navigator !== "undefined"
|
||||
&& "registerProtocolHandler" in navigator
|
||||
&& typeof window !== "undefined"
|
||||
&& window.isSecureContext;
|
||||
}
|
||||
|
||||
function getProtocolHandlerUrl(protocol: Protocol) {
|
||||
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
|
||||
}
|
||||
|
||||
function registerProtocolHandler(protocol: Protocol) {
|
||||
navigator.registerProtocolHandler(
|
||||
protocol,
|
||||
getProtocolHandlerUrl(protocol),
|
||||
);
|
||||
}
|
||||
|
||||
interface ProtocolHandlerSettingsProps {
|
||||
supportsCalendar: boolean;
|
||||
}
|
||||
|
||||
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const [supported, setSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(canRegisterProtocolHandler());
|
||||
}, []);
|
||||
|
||||
const handleOpenModeChange = async (value: string) => {
|
||||
const openMode = value as ProtocolOpenMode;
|
||||
|
||||
if (openMode === "active-session"
|
||||
&& typeof window !== "undefined"
|
||||
&& "Notification" in window
|
||||
&& Notification.permission === "default") {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
|
||||
updateSetting("protocolOpenMode", openMode);
|
||||
};
|
||||
|
||||
const handleRegister = (protocol: Protocol) => {
|
||||
try {
|
||||
registerProtocolHandler(protocol);
|
||||
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
|
||||
} catch {
|
||||
toast.error(t("registration_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderRegistrationControl = (protocol: Protocol) => {
|
||||
return (
|
||||
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
|
||||
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
{!supported && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("unsupported")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
|
||||
{renderRegistrationControl("mailto")}
|
||||
</SettingItem>
|
||||
|
||||
{supportsCalendar && (
|
||||
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
|
||||
{renderRegistrationControl("webcal")}
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
|
||||
<Select
|
||||
value={protocolOpenMode}
|
||||
onChange={handleOpenModeChange}
|
||||
options={[
|
||||
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
|
||||
{ value: "active-session", label: t("protocol_open_mode_active_session") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
// setting isn't yet, mirror it into synced state.
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
|
||||
if (legacy && !tourCompleted) {
|
||||
updateSetting("tourCompleted", true);
|
||||
}
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [tourCompleted, updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tourCompleted) {
|
||||
setHasCompletedTour(false);
|
||||
return;
|
||||
}
|
||||
if (showOnboardingOnNewDevices) {
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
return;
|
||||
} catch { /* */ }
|
||||
}
|
||||
setHasCompletedTour(true);
|
||||
}, [tourCompleted, showOnboardingOnNewDevices]);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
let resumeStep = 0;
|
||||
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const completeTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setHasCompletedTour(true);
|
||||
updateSetting("tourCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const resetTourCompletion = useCallback(() => {
|
||||
setHasCompletedTour(false);
|
||||
updateSetting("tourCompleted", false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const value: TourContextValue = {
|
||||
isActive,
|
||||
|
||||
@@ -142,9 +142,13 @@ interface AvatarProps {
|
||||
className?: string;
|
||||
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
|
||||
disableImages?: boolean;
|
||||
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
|
||||
disableFavicon?: boolean;
|
||||
/** Background color used when no image source resolves. Overrides the hash-based default. */
|
||||
fallbackColor?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||
@@ -226,7 +230,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
|
||||
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
|
||||
const showFavicon =
|
||||
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
!disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
|
||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
@@ -257,7 +261,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
|
||||
title={name || email}
|
||||
>
|
||||
{imgSrc ? (
|
||||
|
||||
@@ -201,15 +201,27 @@ export function FlagCS(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Denmark – Red with a white Nordic cross */
|
||||
export function FlagDK(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 28" width={W} height={H} className={flagClass} {...props}>
|
||||
<path fill="#C8102E" d="M0,0H37V28H0Z" />
|
||||
<path stroke="#fff" strokeWidth="4" d="M0,14h37M14,0v28" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map locale codes to flag components */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||
cs: FlagCS,
|
||||
da: FlagDK,
|
||||
de: FlagDE,
|
||||
en: FlagGB,
|
||||
es: FlagES,
|
||||
fr: FlagFR,
|
||||
it: FlagIT,
|
||||
ja: FlagJP,
|
||||
ko: FlagKR,
|
||||
es: FlagES,
|
||||
it: FlagIT,
|
||||
de: FlagDE,
|
||||
lv: FlagLV,
|
||||
nl: FlagNL,
|
||||
pl: FlagPL,
|
||||
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
cs: FlagCS,
|
||||
};
|
||||
|
||||
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
|
||||
const t = useTranslations("welcome");
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
// setting isn't yet, mirror it into synced state so the user isn't shown
|
||||
// the banner again on this device after the upgrade.
|
||||
try {
|
||||
if (!localStorage.getItem(ONBOARDING_KEY)) {
|
||||
setVisible(true);
|
||||
const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
|
||||
if (legacy && !onboardingCompleted) {
|
||||
updateSetting("onboardingCompleted", true);
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [onboardingCompleted, updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onboardingCompleted) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (showOnboardingOnNewDevices) {
|
||||
try {
|
||||
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
setVisible(false);
|
||||
}, [onboardingCompleted, showOnboardingOnNewDevices]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
updateSetting("onboardingCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_KEY, "true");
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
+7
-1
@@ -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:
|
||||
|
||||
@@ -45,6 +45,7 @@ export default [
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+5
-2
@@ -14,8 +14,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'cs':
|
||||
messages = (await import('../locales/cs/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
case 'da':
|
||||
messages = (await import('../locales/da/common.json')).default;
|
||||
break;
|
||||
case 'de':
|
||||
messages = (await import('../locales/de/common.json')).default;
|
||||
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'es':
|
||||
messages = (await import('../locales/es/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
break;
|
||||
case 'it':
|
||||
messages = (await import('../locales/it/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
+31
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
expect(plainTextToComposerBody("")).toBe("");
|
||||
});
|
||||
|
||||
it("escapes HTML before building composer paragraphs", () => {
|
||||
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
|
||||
"<p><script>alert('x') & "q"</script></p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes line endings and preserves single line breaks", () => {
|
||||
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
|
||||
"<p>line1<br>line2<br>line3</p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("splits paragraphs on blank lines", () => {
|
||||
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
|
||||
"<p>first</p><p>second<br>third</p>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
|
||||
expect(clean).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should remove images from signatures', () => {
|
||||
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
|
||||
it('should allow img with https src', () => {
|
||||
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
|
||||
expect(clean).toContain('alt="Logo"');
|
||||
expect(clean).toContain('width="120"');
|
||||
expect(clean).toContain('height="40"');
|
||||
});
|
||||
|
||||
it('should allow img with data:image/png;base64 src', () => {
|
||||
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
|
||||
const signature = `<img src="${dataUri}" alt="Logo">`;
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('data:image/png;base64,');
|
||||
});
|
||||
|
||||
it('should allow img with data:image/jpeg, gif, webp', () => {
|
||||
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
|
||||
for (const src of cases) {
|
||||
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain(src);
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip img with http: src (https only)', () => {
|
||||
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('http://insecure.example.com');
|
||||
expect(clean).not.toContain('<img');
|
||||
expect(clean).toContain('John');
|
||||
});
|
||||
|
||||
it('should strip img with javascript: src', () => {
|
||||
const signature = '<img src="javascript:alert(1)" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('javascript:');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
|
||||
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('data:image/svg');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip img with non-image data: URI', () => {
|
||||
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('data:text/html');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip event handlers on img', () => {
|
||||
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('onerror');
|
||||
expect(clean).not.toContain('onload');
|
||||
expect(clean).toContain('https://cdn.example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should remove video and audio tags', () => {
|
||||
@@ -113,16 +169,17 @@ describe('email-sanitization', () => {
|
||||
});
|
||||
|
||||
it('should be stricter than email sanitization', () => {
|
||||
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
|
||||
const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video>';
|
||||
const emailClean = sanitizeEmailHtml(html);
|
||||
const signatureClean = sanitizeSignatureHtml(html);
|
||||
|
||||
// Email allows img and table
|
||||
expect(emailClean).toContain('<img');
|
||||
// Email allows table
|
||||
expect(emailClean).toContain('<table>');
|
||||
|
||||
// Signature blocks img but may allow some tables (verify in implementation)
|
||||
expect(signatureClean).not.toContain('<img');
|
||||
// Signature blocks table and video
|
||||
expect(signatureClean).not.toContain('<table');
|
||||
expect(signatureClean).not.toContain('<video');
|
||||
expect(signatureClean).toContain('Text');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseMailto } from "../protocol-handlers/mailto";
|
||||
import { listenForMailtoRequests } from "../protocol-handlers/session";
|
||||
import { parseWebcal } from "../protocol-handlers/webcal";
|
||||
|
||||
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
|
||||
|
||||
function installServiceWorkerMock() {
|
||||
const listeners = new Set<(event: MessageEvent) => void>();
|
||||
const worker = { postMessage: vi.fn() };
|
||||
const serviceWorker = {
|
||||
ready: Promise.resolve({ active: worker }),
|
||||
controller: worker,
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatch(data: unknown) {
|
||||
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalServiceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
describe("protocol handlers", () => {
|
||||
describe("parseMailto", () => {
|
||||
it("parses a single path recipient", () => {
|
||||
expect(parseMailto("mailto:alice@example.com")).toEqual({
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multiple recipients with subject and body", () => {
|
||||
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
|
||||
to: ["alice@example.com", "bob@example.com"],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses to, cc, and bcc query recipients", () => {
|
||||
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
|
||||
to: ["alice@example.com"],
|
||||
cc: ["bob@example.com"],
|
||||
bcc: ["eve@example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes subject and body values", () => {
|
||||
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
|
||||
subject: "Hello World",
|
||||
body: "line1\nline2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal plus signs in query values", () => {
|
||||
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
|
||||
to: ["user+tag@example.com"],
|
||||
subject: "C++",
|
||||
body: "a+b",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-mailto URLs", () => {
|
||||
expect(parseMailto("https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows an empty mailto URL", () => {
|
||||
expect(parseMailto("mailto:")).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes control characters and caps recipients", () => {
|
||||
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
|
||||
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
|
||||
expect(parsed?.to).toHaveLength(200);
|
||||
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWebcal", () => {
|
||||
it("normalizes webcal to https", () => {
|
||||
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("normalizes webcals to https", () => {
|
||||
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
|
||||
});
|
||||
|
||||
it("suggests a name from the path", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
|
||||
});
|
||||
|
||||
it("falls back to hostname for suggested name", () => {
|
||||
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
|
||||
});
|
||||
|
||||
it("prefers a name query parameter", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForMailtoRequests", () => {
|
||||
const mailtoValue = {
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
};
|
||||
|
||||
it("accepts legacy service-worker mailto messages without a client id", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
vi.spyOn(window, "focus").mockImplementation(() => undefined);
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
|
||||
|
||||
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores service-worker mailto messages for another client", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
|
||||
|
||||
expect(onMailto).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
@@ -49,4 +49,39 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
|
||||
/**
|
||||
* 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.
|
||||
* cookies on average - within Firefox's per-domain limit of 150.
|
||||
*/
|
||||
export const MAX_ACCOUNT_SLOTS = 50;
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MAX_ACCOUNTS_HTTP1 = 5;
|
||||
* 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.
|
||||
* under-detect and fall back to the conservative cap - that's safe.
|
||||
*/
|
||||
export function isHttp2Available(): boolean {
|
||||
if (typeof performance === 'undefined') return false;
|
||||
|
||||
+7
-14
@@ -1,28 +1,23 @@
|
||||
import { appendFile, stat, rename, mkdir } from 'node:fs/promises';
|
||||
import { appendFile, stat, rename, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import 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
@@ -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());
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
ensureConfigDir,
|
||||
ensureStateDir,
|
||||
getConfigPath,
|
||||
getStatePath,
|
||||
isConfigReadOnly,
|
||||
} from './paths';
|
||||
import type { AdminConfigData, AdminStateData } from './types';
|
||||
|
||||
const MIGRATION_MARKER = '.migrated-v2';
|
||||
|
||||
interface LegacyAdminData {
|
||||
passwordHash: string;
|
||||
createdAt?: string;
|
||||
lastLogin?: string | null;
|
||||
passwordChangedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot migration from the v1 layout (everything mixed in `data/admin/`)
|
||||
* to the v2 layout (config + state split, see lib/admin/paths.ts).
|
||||
*
|
||||
* Idempotent: writes a `.migrated-v2` marker into the config dir on success.
|
||||
*
|
||||
* Migrations performed:
|
||||
* 1. admin.json with timestamps → admin.json (passwordHash only) +
|
||||
* admin-state.json (createdAt, lastLogin, passwordChangedAt)
|
||||
* 2. audit.log moved from config dir to state dir (by rename if same FS,
|
||||
* else copy + delete).
|
||||
*
|
||||
* Skipped silently when the config dir is read-only - operators who already
|
||||
* locked their config volume must do the migration manually before mounting
|
||||
* :ro.
|
||||
*/
|
||||
export async function migrateLegacyAdminLayout(): Promise<void> {
|
||||
if (isConfigReadOnly()) return;
|
||||
|
||||
const markerPath = getConfigPath(MIGRATION_MARKER);
|
||||
if (existsSync(markerPath)) return;
|
||||
|
||||
let didWork = false;
|
||||
|
||||
try {
|
||||
didWork = (await migrateAdminJson()) || didWork;
|
||||
didWork = (await migrateAuditLog()) || didWork;
|
||||
|
||||
await ensureConfigDir();
|
||||
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
|
||||
if (didWork) {
|
||||
logger.info('Admin layout migrated to v2 (config/state split)');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Admin layout migration failed; will retry on next boot', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the existing admin.json carries timestamp fields (legacy mixed layout),
|
||||
* split them into admin-state.json and rewrite admin.json without them.
|
||||
* Returns true if a migration was performed.
|
||||
*/
|
||||
async function migrateAdminJson(): Promise<boolean> {
|
||||
const adminJsonPath = getConfigPath('admin.json');
|
||||
if (!existsSync(adminJsonPath)) return false;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(adminJsonPath, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let data: LegacyAdminData;
|
||||
try {
|
||||
data = JSON.parse(raw) as LegacyAdminData;
|
||||
} catch {
|
||||
logger.warn('admin.json is not valid JSON; skipping migration');
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasLegacyFields =
|
||||
'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data;
|
||||
if (!hasLegacyFields) return false; // already in v2 shape
|
||||
|
||||
if (!data.passwordHash || typeof data.passwordHash !== 'string') {
|
||||
logger.warn('admin.json missing passwordHash; skipping migration');
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const stateData: AdminStateData = {
|
||||
createdAt: data.createdAt ?? now,
|
||||
lastLogin: data.lastLogin ?? null,
|
||||
passwordChangedAt: data.passwordChangedAt ?? now,
|
||||
};
|
||||
const configData: AdminConfigData = { passwordHash: data.passwordHash };
|
||||
|
||||
await ensureStateDir();
|
||||
const statePath = getStatePath('admin-state.json');
|
||||
|
||||
// If admin-state.json already exists, prefer its values: a previous
|
||||
// migration may have succeeded and recorded fresh login timestamps that
|
||||
// we'd otherwise stomp. The legacy admin.json data is older by definition.
|
||||
if (!existsSync(statePath)) {
|
||||
const stateTmp = statePath + '.tmp';
|
||||
await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8');
|
||||
await rename(stateTmp, statePath);
|
||||
}
|
||||
|
||||
const configTmp = adminJsonPath + '.tmp';
|
||||
await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8');
|
||||
await rename(configTmp, adminJsonPath);
|
||||
|
||||
logger.info('Migrated admin.json: split timestamps into admin-state.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move audit.log from the config dir to the state dir if present. Returns
|
||||
* true if a migration was performed. Also moves rotated copies (audit.log.1
|
||||
* through .3).
|
||||
*/
|
||||
async function migrateAuditLog(): Promise<boolean> {
|
||||
const sources = [
|
||||
'audit.log',
|
||||
'audit.log.1',
|
||||
'audit.log.2',
|
||||
'audit.log.3',
|
||||
];
|
||||
|
||||
let moved = false;
|
||||
for (const name of sources) {
|
||||
const src = getConfigPath(name);
|
||||
if (!existsSync(src)) continue;
|
||||
|
||||
await ensureStateDir();
|
||||
const dst = getStatePath(name);
|
||||
|
||||
try {
|
||||
// Same-FS rename is atomic. Falls through to copy if cross-device.
|
||||
await rename(src, dst);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EXDEV') {
|
||||
// Cross-device: copy bytes, then delete source.
|
||||
const data = await readFile(src);
|
||||
await writeFile(dst, data);
|
||||
await unlink(src);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
logger.info('Migrated audit.log to state dir');
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns approximate size of legacy data still mixed in the config dir
|
||||
* (for diagnostics / admin UI). Always returns 0 once migration has run.
|
||||
*/
|
||||
export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> {
|
||||
let adminJsonHasTimestamps = false;
|
||||
const adminJsonPath = getConfigPath('admin.json');
|
||||
if (existsSync(adminJsonPath)) {
|
||||
try {
|
||||
const raw = await readFile(adminJsonPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
adminJsonHasTimestamps =
|
||||
'createdAt' in parsed ||
|
||||
'lastLogin' in parsed ||
|
||||
'passwordChangedAt' in parsed;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let auditLogInConfigDir = false;
|
||||
try {
|
||||
await stat(getConfigPath('audit.log'));
|
||||
auditLogInConfigDir = true;
|
||||
} catch {
|
||||
/* not present - good */
|
||||
}
|
||||
|
||||
return { adminJsonHasTimestamps, auditLogInConfigDir };
|
||||
}
|
||||
+115
-84
@@ -1,9 +1,14 @@
|
||||
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { 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;
|
||||
}
|
||||
|
||||
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;
|
||||
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
||||
cachedConfig = { passwordHash: hash };
|
||||
cachedState = freshState();
|
||||
await writeConfigData(cachedConfig);
|
||||
await writeStateData(cachedState);
|
||||
initialized = true;
|
||||
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
|
||||
if (isHashed(envPassword)) {
|
||||
logger.info('Admin password hash saved to admin.json from environment variable');
|
||||
} else {
|
||||
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* Admin data directories.
|
||||
*
|
||||
* Two dirs intentionally split (issue #226):
|
||||
* - CONFIG: holds operator-authored state (config.json, policy.json,
|
||||
* admin.json passwordHash, plugins, themes, branding uploads). Can be
|
||||
* mounted read-only after initial setup.
|
||||
* - STATE: holds runtime mutations (admin-state.json with login timestamps,
|
||||
* audit.log, .setup-token). Always read-write.
|
||||
*
|
||||
* Resolution order:
|
||||
* getConfigDir()
|
||||
* 1. ADMIN_CONFIG_DIR
|
||||
* 2. ADMIN_DATA_DIR (legacy)
|
||||
* 3. <cwd>/data/admin
|
||||
*
|
||||
* getStateDir()
|
||||
* 1. ADMIN_STATE_DIR
|
||||
* 2. <ADMIN_CONFIG_DIR>/state - if config dir was set explicitly
|
||||
* 3. <ADMIN_DATA_DIR>/state - back-compat: stays on the legacy volume
|
||||
* 4. <cwd>/data/admin-state - fresh-install default; matches the
|
||||
* sibling mount in docker-compose.yml
|
||||
*
|
||||
* The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working
|
||||
* unchanged: everything ends up under it, with state in a `state/` subdir.
|
||||
* Fresh installs and the docker-compose default keep state in a separate
|
||||
* sibling dir so the config dir can be mounted :ro after setup.
|
||||
*/
|
||||
|
||||
export function getConfigDir(): string {
|
||||
return (
|
||||
process.env.ADMIN_CONFIG_DIR ||
|
||||
process.env.ADMIN_DATA_DIR ||
|
||||
path.join(process.cwd(), 'data', 'admin')
|
||||
);
|
||||
}
|
||||
|
||||
export function getStateDir(): string {
|
||||
if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR;
|
||||
if (process.env.ADMIN_CONFIG_DIR) {
|
||||
return path.join(process.env.ADMIN_CONFIG_DIR, 'state');
|
||||
}
|
||||
if (process.env.ADMIN_DATA_DIR) {
|
||||
return path.join(process.env.ADMIN_DATA_DIR, 'state');
|
||||
}
|
||||
return path.join(process.cwd(), 'data', 'admin-state');
|
||||
}
|
||||
|
||||
export function getConfigPath(filename: string): string {
|
||||
return path.join(getConfigDir(), filename);
|
||||
}
|
||||
|
||||
export function getStatePath(filename: string): string {
|
||||
return path.join(getStateDir(), filename);
|
||||
}
|
||||
|
||||
export async function ensureConfigDir(): Promise<void> {
|
||||
const dir = getConfigDir();
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStateDir(): Promise<void> {
|
||||
const dir = getStateDir();
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Read-only mode ─────────────────────────────────────────────────────────
|
||||
|
||||
let cachedReadOnly: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true
|
||||
* after running the setup wizard and remounting the volume :ro.
|
||||
*
|
||||
* When true, all writes to the config dir are refused at the application
|
||||
* layer (cleaner error than a mid-request EROFS).
|
||||
*/
|
||||
export function isConfigReadOnly(): boolean {
|
||||
if (cachedReadOnly !== null) return cachedReadOnly;
|
||||
const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase();
|
||||
cachedReadOnly = v === 'true' || v === '1' || v === 'yes';
|
||||
return cachedReadOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the config dir by writing a temp file. Used to auto-detect RO mounts
|
||||
* when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup;
|
||||
* cheap on local FS, can be slow on networked FS, hence opt-in.
|
||||
*/
|
||||
export async function probeConfigReadOnly(): Promise<boolean> {
|
||||
if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly();
|
||||
try {
|
||||
const probe = path.join(getConfigDir(), '.rw-probe');
|
||||
await writeFile(probe, '');
|
||||
await unlink(probe);
|
||||
cachedReadOnly = false;
|
||||
return false;
|
||||
} catch {
|
||||
cachedReadOnly = true;
|
||||
logger.info('Config dir is read-only (auto-detected)');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigReadOnlyError extends Error {
|
||||
constructor(operation: string) {
|
||||
super(
|
||||
`Cannot ${operation}: configuration is read-only. ` +
|
||||
`Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.`
|
||||
);
|
||||
this.name = 'ConfigReadOnlyError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWritable(operation: string): void {
|
||||
if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation);
|
||||
}
|
||||
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import 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 */ }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,5 +1,16 @@
|
||||
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
|
||||
|
||||
// randomuser.me serves stable portrait URLs at
|
||||
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
|
||||
// See https://randomuser.me/documentation#howto - we use these directly
|
||||
// rather than hitting the JSON API so the demo works offline.
|
||||
const portrait = (gender: 'men' | 'women', n: number): string =>
|
||||
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||
|
||||
const photo = (gender: 'men' | 'women', n: number) => ({
|
||||
photo1: { kind: 'photo' as const, uri: portrait(gender, n), mediaType: 'image/jpeg' },
|
||||
});
|
||||
|
||||
export function createDemoAddressBooks(): AddressBook[] {
|
||||
return [
|
||||
{
|
||||
@@ -34,6 +45,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
|
||||
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
|
||||
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
|
||||
media: photo('women', 44),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-2',
|
||||
@@ -50,6 +62,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
},
|
||||
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
|
||||
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
|
||||
media: photo('men', 32),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-3',
|
||||
@@ -60,6 +73,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
|
||||
organizations: { o1: { name: 'DesignCo' } },
|
||||
titles: { t1: { name: 'UX Designer', kind: 'title' } },
|
||||
media: photo('women', 68),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-4',
|
||||
@@ -69,6 +83,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
|
||||
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
|
||||
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
|
||||
media: photo('men', 15),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-5',
|
||||
@@ -89,6 +104,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
},
|
||||
},
|
||||
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
|
||||
media: photo('women', 22),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-6',
|
||||
@@ -97,6 +113,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
|
||||
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
|
||||
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
|
||||
media: photo('men', 67),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-7',
|
||||
@@ -123,6 +140,58 @@ export function createDemoContacts(): ContactCard[] {
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
|
||||
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
|
||||
media: photo('women', 85),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-16',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sofia' }, { kind: 'surname', value: 'Russo' }] },
|
||||
emails: { e1: { address: 'sofia.russo@example.com', contexts: { private: true }, pref: 1 } },
|
||||
phones: { p1: { number: '+39-340-555-0111', features: { cell: true }, contexts: { private: true } } },
|
||||
notes: { n1: { note: 'Mom' } },
|
||||
anniversaries: { a1: { kind: 'birth', date: { year: 1962, month: 5, day: 9 } } },
|
||||
media: photo('women', 3),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-17',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
|
||||
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
|
||||
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
|
||||
notes: { n1: { note: 'Sister - lives in Kraków' } },
|
||||
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
|
||||
media: photo('women', 47),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-18',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
|
||||
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
|
||||
notes: { n1: { note: 'College friend - book club organiser' } },
|
||||
media: photo('men', 96),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-19',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Bennett' }] },
|
||||
emails: { e1: { address: 'olivia.bennett@example.com', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Northwind Studio' } },
|
||||
titles: { t1: { name: 'Product Designer', kind: 'title' } },
|
||||
media: photo('women', 91),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-20',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Daniel' }, { kind: 'surname', value: 'Cooper' }] },
|
||||
emails: { e1: { address: 'daniel.cooper@example.com', pref: 1 } },
|
||||
organizations: { o1: { name: 'Freelance' } },
|
||||
titles: { t1: { name: 'Illustrator', kind: 'title' } },
|
||||
media: photo('men', 76),
|
||||
},
|
||||
|
||||
// ── Work address book ──────────────────────────────────────
|
||||
@@ -135,6 +204,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
|
||||
titles: { t1: { name: 'Product Manager', kind: 'title' } },
|
||||
media: photo('men', 41),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-10',
|
||||
@@ -144,6 +214,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
|
||||
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
|
||||
media: photo('women', 12),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-11',
|
||||
@@ -153,6 +224,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
|
||||
titles: { t1: { name: 'CTO', kind: 'title' } },
|
||||
media: photo('men', 52),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-12',
|
||||
@@ -162,6 +234,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
|
||||
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
|
||||
media: photo('women', 77),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-13',
|
||||
@@ -171,6 +244,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
|
||||
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
|
||||
media: photo('men', 89),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-14',
|
||||
@@ -180,6 +254,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
|
||||
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
|
||||
media: photo('women', 55),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-15',
|
||||
@@ -189,6 +264,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
|
||||
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
|
||||
media: photo('men', 8),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+590
-110
@@ -1,6 +1,35 @@
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { demoDate } from '../demo-utils';
|
||||
|
||||
const USER = { name: 'Demo User', email: 'demo@example.com' } as const;
|
||||
|
||||
// Helper to keep the fixtures short - auto-assigns a partId/blobId per body.
|
||||
let bodyCounter = 0;
|
||||
function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') {
|
||||
const partId = String(++bodyCounter);
|
||||
const blobId = `blob-${partId}`;
|
||||
return {
|
||||
part: { partId, blobId, size: value.length, type },
|
||||
values: { [partId]: { value } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Build text+html parts in one shot. */
|
||||
function bodies(text: string, html: string) {
|
||||
const t = body(text, 'text/plain');
|
||||
const h = body(html, 'text/html');
|
||||
return {
|
||||
textBody: [t.part],
|
||||
htmlBody: [h.part],
|
||||
bodyValues: { ...t.values, ...h.values },
|
||||
};
|
||||
}
|
||||
|
||||
function textOnly(text: string) {
|
||||
const t = body(text, 'text/plain');
|
||||
return { textBody: [t.part], bodyValues: t.values };
|
||||
}
|
||||
|
||||
export function createDemoEmails(): Email[] {
|
||||
return [
|
||||
// ── Inbox ───────────────────────────────────────────────────
|
||||
@@ -12,19 +41,61 @@ export function createDemoEmails(): Email[] {
|
||||
size: 4200,
|
||||
receivedAt: demoDate(0, -2),
|
||||
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'Welcome to Bulwark Mail!',
|
||||
sentAt: demoDate(0, -2),
|
||||
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
|
||||
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
|
||||
},
|
||||
...bodies(
|
||||
'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!',
|
||||
'<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>',
|
||||
),
|
||||
messageId: '<welcome@demo.bulwark.email>',
|
||||
},
|
||||
|
||||
// Mom - personal message, unread
|
||||
{
|
||||
id: 'demo-email-mom',
|
||||
threadId: 'demo-thread-mom',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 1900,
|
||||
receivedAt: demoDate(0, -4, -12),
|
||||
from: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'when are you coming home?',
|
||||
sentAt: demoDate(0, -4, -12),
|
||||
preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi sweetie,\n\nYour father and I were just talking - we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom",
|
||||
),
|
||||
messageId: '<5a8c-mom@example.com>',
|
||||
},
|
||||
|
||||
// GitHub - PR review request
|
||||
{
|
||||
id: 'demo-email-gh-pr',
|
||||
threadId: 'demo-thread-gh-pr',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 6400,
|
||||
receivedAt: demoDate(0, -3, -5),
|
||||
from: [{ name: 'Alice Johnson (via GitHub)', email: 'notifications@github.com' }],
|
||||
replyTo: [{ name: 'reply', email: 'reply+abc123@reply.github.com' }],
|
||||
to: [USER],
|
||||
subject: '[acme/api-gateway] Add token-bucket rate limiter (#1284)',
|
||||
sentAt: demoDate(0, -3, -5),
|
||||
preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...',
|
||||
hasAttachment: false,
|
||||
...bodies(
|
||||
'@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 −47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n-\nReply to this email directly, or view it on GitHub.',
|
||||
'<table style="font-family:-apple-system,sans-serif"><tr><td><strong>@demo-user</strong> requested your review on this pull request.</td></tr><tr><td style="padding-top:12px">Replaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in <code>rate-limit.toml</code>.</td></tr><tr><td style="padding-top:12px;color:#666">Three files changed, <span style="color:#16a34a">+312</span> <span style="color:#dc2626">−47</span></td></tr><tr><td style="padding-top:16px"><a href="https://github.com/acme/api-gateway/pull/1284" style="background:#1f2328;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px">View on GitHub</a></td></tr></table>',
|
||||
),
|
||||
messageId: '<acme/api-gateway/pull/1284@github.com>',
|
||||
},
|
||||
|
||||
// Hacker Newsletter - newsletter, read
|
||||
{
|
||||
id: 'demo-email-2',
|
||||
threadId: 'demo-thread-2',
|
||||
@@ -33,20 +104,19 @@ export function createDemoEmails(): Email[] {
|
||||
size: 18500,
|
||||
receivedAt: demoDate(-1, -5),
|
||||
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'This Week in Tech: AI Developments & Open Source Updates',
|
||||
to: [USER],
|
||||
subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla',
|
||||
sentAt: demoDate(-1, -5),
|
||||
preview: 'Your weekly roundup of the most important technology news and open source developments...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
|
||||
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
|
||||
},
|
||||
messageId: '<weekly-42@techdigest.example>',
|
||||
...bodies(
|
||||
'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.',
|
||||
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;line-height:1.5"><div style="border-bottom:2px solid #111;padding-bottom:16px"><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">TechDigest · Issue #218</div><h1 style="font-size:22px;margin:4px 0 0">RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla</h1></div><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">The week in standards</h2><p><strong>1.</strong> RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a <a href="#" style="color:#db2d54">write-up</a> that runs through what changes for transactional senders.</p><p><strong>2.</strong> WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is <em>almost</em> in but punted to a separate spec, which feels like the right call.</p><p><strong>3.</strong> Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.</p><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Tools</h2><ul><li>Datasette 1.0 is out. Ten years from the first commit.</li><li>Fly.io published their object store, Tigris-style, written in Go.</li><li>Linear added an SSO migration tool that actually handles the IdP-initiated case.</li></ul><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Essays</h2><p style="margin:0 0 6px">"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.</p><p style="margin:0">"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.</p><div style="margin-top:28px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#888">Manage your subscription at <a href="#" style="color:#888">techdigest.example/manage</a></div></div>',
|
||||
),
|
||||
messageId: '<weekly-218@techdigest.example>',
|
||||
},
|
||||
// Thread: Project discussion (3 emails in same thread)
|
||||
|
||||
// Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages)
|
||||
{
|
||||
id: 'demo-email-3a',
|
||||
threadId: 'demo-thread-3',
|
||||
@@ -55,17 +125,15 @@ export function createDemoEmails(): Email[] {
|
||||
size: 3100,
|
||||
receivedAt: demoDate(-3, -10),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
to: [USER, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
subject: 'Q4 Project Timeline',
|
||||
sentAt: demoDate(-3, -10),
|
||||
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
|
||||
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
|
||||
},
|
||||
...bodies(
|
||||
'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice',
|
||||
'<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>',
|
||||
),
|
||||
messageId: '<q4-timeline-1@example.com>',
|
||||
},
|
||||
{
|
||||
@@ -76,15 +144,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 3500,
|
||||
receivedAt: demoDate(-2, -8),
|
||||
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, USER],
|
||||
subject: 'Re: Q4 Project Timeline',
|
||||
sentAt: demoDate(-2, -8),
|
||||
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob' },
|
||||
},
|
||||
...textOnly(
|
||||
"Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob",
|
||||
),
|
||||
messageId: '<q4-timeline-2@example.com>',
|
||||
inReplyTo: ['<q4-timeline-1@example.com>'],
|
||||
references: ['<q4-timeline-1@example.com>'],
|
||||
@@ -97,20 +164,41 @@ export function createDemoEmails(): Email[] {
|
||||
size: 3800,
|
||||
receivedAt: demoDate(-1, -3),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, USER],
|
||||
subject: 'Re: Q4 Project Timeline',
|
||||
sentAt: demoDate(-1, -3),
|
||||
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
|
||||
preview: "Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today...",
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice' },
|
||||
},
|
||||
...textOnly(
|
||||
"Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice",
|
||||
),
|
||||
messageId: '<q4-timeline-3@example.com>',
|
||||
inReplyTo: ['<q4-timeline-2@example.com>'],
|
||||
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
|
||||
},
|
||||
// Email with attachments
|
||||
|
||||
// Stripe receipt
|
||||
{
|
||||
id: 'demo-email-stripe',
|
||||
threadId: 'demo-thread-stripe',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 11200,
|
||||
receivedAt: demoDate(-1, -1, -22),
|
||||
from: [{ name: 'Stripe', email: 'receipts@stripe.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your receipt from Linear Inc. [#2451-9928]',
|
||||
sentAt: demoDate(-1, -1, -22),
|
||||
preview: 'Receipt from Linear Inc. for $16.00. Thanks for your business.',
|
||||
hasAttachment: false,
|
||||
...bodies(
|
||||
'Receipt from Linear Inc.\nAmount paid: $16.00\nDate paid: yesterday\nPayment method: Visa •••• 4242\n\nDescription: Linear Standard (monthly)\n\nReceipt #2451-9928\n\nThis charge will appear on your statement as LINEAR INC.\n\nQuestions? Contact support@linear.app.',
|
||||
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif"><div style="text-align:center;padding:24px 0"><div style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase">Receipt</div><div style="font-size:32px;font-weight:700;margin-top:4px">$16.00</div><div style="color:#666;margin-top:4px">Linear Inc.</div></div><table style="width:100%;border-top:1px solid #eee;border-bottom:1px solid #eee"><tr><td style="padding:10px 0;color:#666">Amount</td><td style="padding:10px 0;text-align:right">$16.00</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Payment method</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4">Visa •••• 4242</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Receipt number</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4;font-family:monospace">2451-9928</td></tr></table><p style="color:#666;font-size:13px;margin-top:24px">Description: Linear Standard (monthly). This charge will appear on your statement as LINEAR INC.</p></div>',
|
||||
),
|
||||
messageId: '<receipt-2451-9928@stripe.com>',
|
||||
},
|
||||
|
||||
// Email with attachments - invoice
|
||||
{
|
||||
id: 'demo-email-4',
|
||||
threadId: 'demo-thread-4',
|
||||
@@ -119,22 +207,22 @@ export function createDemoEmails(): Email[] {
|
||||
size: 245000,
|
||||
receivedAt: demoDate(0, -6),
|
||||
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Invoice #2024-089 & Project Screenshot',
|
||||
to: [USER],
|
||||
subject: 'Invoice #2024-089 & landing-page prototype v3',
|
||||
sentAt: demoDate(0, -6),
|
||||
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
|
||||
preview: "Hi, please find attached the invoice for October and a screenshot of the latest prototype...",
|
||||
hasAttachment: true,
|
||||
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
|
||||
},
|
||||
...textOnly(
|
||||
"Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\n\nBest regards,\nSarah",
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
|
||||
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
|
||||
],
|
||||
messageId: '<invoice-089@example.com>',
|
||||
},
|
||||
// Starred email
|
||||
|
||||
// Carlos - starred, social
|
||||
{
|
||||
id: 'demo-email-5',
|
||||
threadId: 'demo-thread-5',
|
||||
@@ -143,18 +231,286 @@ export function createDemoEmails(): Email[] {
|
||||
size: 2800,
|
||||
receivedAt: demoDate(-2, -1),
|
||||
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Reminder: Team Dinner Friday',
|
||||
to: [USER],
|
||||
subject: 'Friday dinner - moved to 7:30 (sorry!)',
|
||||
sentAt: demoDate(-2, -1),
|
||||
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
|
||||
preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
|
||||
},
|
||||
...textOnly(
|
||||
"Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos",
|
||||
),
|
||||
messageId: '<dinner-reminder@example.com>',
|
||||
},
|
||||
|
||||
// Linear - issue assigned
|
||||
{
|
||||
id: 'demo-email-linear',
|
||||
threadId: 'demo-thread-linear',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 5400,
|
||||
receivedAt: demoDate(0, -7, -15),
|
||||
from: [{ name: 'Linear', email: 'notifications@linear.app' }],
|
||||
to: [USER],
|
||||
subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"',
|
||||
sentAt: demoDate(0, -7, -15),
|
||||
preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...',
|
||||
hasAttachment: false,
|
||||
...bodies(
|
||||
"Priya Sharma assigned BUL-2031 to you.\n\nTitle: Compose: drag-and-drop attachments duplicated on slow networks\nPriority: Medium\n\nRepro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn't get debounced and both attempts complete, so the attachment shows up twice in the draft.\n\nOpen in Linear: https://linear.app/bulwark/issue/BUL-2031",
|
||||
'<table style="font-family:-apple-system,sans-serif;max-width:520px"><tr><td><div style="font-size:11px;color:#888;letter-spacing:0.08em;text-transform:uppercase">Linear · BUL-2031</div><div style="font-size:18px;font-weight:600;margin-top:6px">Compose: drag-and-drop attachments duplicated on slow networks</div><div style="margin-top:8px;color:#666"><strong>Priya Sharma</strong> assigned this issue to you · Priority Medium</div></td></tr><tr><td style="padding-top:16px;color:#444">Repro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn\'t get debounced and both attempts complete, so the attachment shows up twice in the draft.</td></tr><tr><td style="padding-top:16px"><a href="https://linear.app/bulwark/issue/BUL-2031" style="background:#5e6ad2;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px;font-size:13px">Open in Linear</a></td></tr></table>',
|
||||
),
|
||||
messageId: '<BUL-2031-assign@linear.app>',
|
||||
},
|
||||
|
||||
// Anna - sister, photos
|
||||
{
|
||||
id: 'demo-email-anna',
|
||||
threadId: 'demo-thread-anna',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 4800000,
|
||||
receivedAt: demoDate(-1, -19),
|
||||
from: [{ name: 'Anna Kowalski', email: 'anna.kowalski@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'photos from the wedding',
|
||||
sentAt: demoDate(-1, -19),
|
||||
preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...",
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
"ok finally got around to going through these. there are like 600 more on the drive but here's the highlights - the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na",
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' },
|
||||
{ partId: 'att-4', blobId: 'demo-blob-att-4', size: 1600000, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' },
|
||||
{ partId: 'att-5', blobId: 'demo-blob-att-5', size: 1400000, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' },
|
||||
],
|
||||
messageId: '<wedding-photos@example.com>',
|
||||
},
|
||||
|
||||
// AWS billing
|
||||
{
|
||||
id: 'demo-email-aws',
|
||||
threadId: 'demo-thread-aws',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 9100,
|
||||
receivedAt: demoDate(-2, -3, -45),
|
||||
from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your AWS bill is available - $127.43',
|
||||
sentAt: demoDate(-2, -3, -45),
|
||||
preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 - $61.20\n S3 - $28.94\n Route 53 - $14.50\n CloudFront - $11.02\n Other - $11.77\n\nView the full invoice in the Billing Console.",
|
||||
),
|
||||
messageId: '<aws-bill-2024-11@amazon.com>',
|
||||
},
|
||||
|
||||
// 2FA code - system, unread
|
||||
{
|
||||
id: 'demo-email-2fa',
|
||||
threadId: 'demo-thread-2fa',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 1700,
|
||||
receivedAt: demoDate(0, -1, -8),
|
||||
from: [{ name: '1Password', email: 'noreply@1password.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your one-time verification code is 814-302',
|
||||
sentAt: demoDate(0, -1, -8),
|
||||
preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email - your account remains secure.",
|
||||
),
|
||||
messageId: '<otp-814302@1password.com>',
|
||||
},
|
||||
|
||||
// LinkedIn - cold-ish
|
||||
{
|
||||
id: 'demo-email-linkedin',
|
||||
threadId: 'demo-thread-linkedin',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 8200,
|
||||
receivedAt: demoDate(-3, -11),
|
||||
from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }],
|
||||
to: [USER],
|
||||
subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog',
|
||||
sentAt: demoDate(-3, -11),
|
||||
preview: "We thought you'd be interested in these jobs based on your profile and search history.",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer - Datadog (Remote, EU)\n2. Principal Engineer, Platform - Sentry (Remote, EU)\n3. Staff Backend Engineer - Linear (Remote)\n4. Tech Lead, Infrastructure - Tailscale (Remote, EU)\n5. Staff Engineer, Mobile - Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.',
|
||||
),
|
||||
messageId: '<jobs-1107@linkedin.com>',
|
||||
},
|
||||
|
||||
// Book club - Marcus
|
||||
{
|
||||
id: 'demo-email-bookclub',
|
||||
threadId: 'demo-thread-bookclub',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 2400,
|
||||
receivedAt: demoDate(-1, -14),
|
||||
from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }],
|
||||
to: [USER, { name: 'Emma Wilson', email: 'emma.wilson@example.com' }, { name: 'David Park', email: 'david.park@example.com' }],
|
||||
subject: 'book club thursday - picking the next one',
|
||||
sentAt: demoDate(-1, -14),
|
||||
preview: 'Reminder: 7pm at mine. We finish off Le Guin and pick the next read. My vote is the Calvino but I know Emma...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Reminder: 7pm at mine. We finish off Le Guin and pick the next read.\n\nMy vote is the Calvino but I know Emma's been pushing for the Knausgaard. I'll bring wine, can someone else handle snacks?\n\nm",
|
||||
),
|
||||
messageId: '<bookclub-nov@example.com>',
|
||||
},
|
||||
|
||||
// DHL package
|
||||
{
|
||||
id: 'demo-email-dhl',
|
||||
threadId: 'demo-thread-dhl',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 5600,
|
||||
receivedAt: demoDate(0, -9, -30),
|
||||
from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your package is out for delivery - arriving today',
|
||||
sentAt: demoDate(0, -9, -30),
|
||||
preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Your package is on the truck.\n\nTracking: 1Z 999 AA1 0123 4567 84\nEstimated delivery window: today, 14:00–18:00\n\nIf no one is home, the driver will attempt redelivery tomorrow or leave it at the nearest pickup point.\n\nTrack live at dhl.com/track.',
|
||||
),
|
||||
messageId: '<delivery-1Z999AA1@dhl.com>',
|
||||
},
|
||||
|
||||
// Notion
|
||||
{
|
||||
id: 'demo-email-notion',
|
||||
threadId: 'demo-thread-notion',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 4100,
|
||||
receivedAt: demoDate(-2, -16),
|
||||
from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }],
|
||||
to: [USER],
|
||||
subject: 'Olivia shared "Q1 2026 - design north star" with you',
|
||||
sentAt: demoDate(-2, -16),
|
||||
preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 - design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star',
|
||||
),
|
||||
messageId: '<share-northwind-q1@mail.notion.so>',
|
||||
},
|
||||
|
||||
// Spotify wrap
|
||||
{
|
||||
id: 'demo-email-spotify',
|
||||
threadId: 'demo-thread-spotify',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 7400,
|
||||
receivedAt: demoDate(-4, -8),
|
||||
from: [{ name: 'Spotify', email: 'no-reply@spotify.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your year in music is ready',
|
||||
sentAt: demoDate(-4, -8),
|
||||
preview: 'You spent 38,420 minutes listening this year. Your top artist was Big Thief, and your top genre was indie folk.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Your year, in music.\n\n38,420 minutes listened\nTop artist: Big Thief\nTop song: "Vampire Empire"\nTop genre: indie folk\nDiscover Weekly hit rate: 41%\n\nOpen Spotify to see your full Wrapped.',
|
||||
),
|
||||
messageId: '<wrapped-2025@spotify.com>',
|
||||
},
|
||||
|
||||
// Booking.com confirmation
|
||||
{
|
||||
id: 'demo-email-booking',
|
||||
threadId: 'demo-thread-booking',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 32100,
|
||||
receivedAt: demoDate(-5, -10),
|
||||
from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }],
|
||||
to: [USER],
|
||||
subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 22–25)',
|
||||
sentAt: demoDate(-5, -10),
|
||||
preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.',
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
'Your booking is confirmed.\n\nHotel Lago, Lake Como (Italy)\nCheck-in: Dec 22, after 15:00\nCheck-out: Dec 25, before 11:00\n\nRoom: Lake-view double, breakfast included\nTotal: €612 (paid)\n\nConfirmation number: 4892-7714-3320\n\nYour voucher is attached. Show it at reception.',
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-6', blobId: 'demo-blob-att-6', size: 31000, name: 'booking-voucher-4892-7714-3320.pdf', type: 'application/pdf' },
|
||||
],
|
||||
messageId: '<conf-4892-7714-3320@booking.com>',
|
||||
},
|
||||
|
||||
// Substack post
|
||||
{
|
||||
id: 'demo-email-substack',
|
||||
threadId: 'demo-thread-substack',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 22400,
|
||||
receivedAt: demoDate(-1, -12),
|
||||
from: [{ name: 'Robin Sloan', email: 'robin@substack.com' }],
|
||||
to: [USER],
|
||||
subject: 'a small newsletter about a small forge',
|
||||
sentAt: demoDate(-1, -12),
|
||||
preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month -\nR.",
|
||||
),
|
||||
messageId: '<nov-2025@robin.substack.com>',
|
||||
},
|
||||
|
||||
// Recruiter cold outreach
|
||||
{
|
||||
id: 'demo-email-recruiter',
|
||||
threadId: 'demo-thread-recruiter',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 3200,
|
||||
receivedAt: demoDate(0, -10),
|
||||
from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
||||
to: [USER],
|
||||
subject: 'Senior role - Distributed Systems - €180-220k + equity',
|
||||
sentAt: demoDate(0, -10),
|
||||
preview: "Hi, I came across your profile and thought you'd be a great fit for a senior position with one of our clients...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients - a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners",
|
||||
),
|
||||
messageId: '<outreach-jh-2025-11@talent-partners.example>',
|
||||
},
|
||||
|
||||
// Dentist reminder
|
||||
{
|
||||
id: 'demo-email-dentist',
|
||||
threadId: 'demo-thread-dentist',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 2200,
|
||||
receivedAt: demoDate(-1, -2),
|
||||
from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }],
|
||||
to: [USER],
|
||||
subject: 'Appointment reminder - Tuesday at 10:00',
|
||||
sentAt: demoDate(-1, -2),
|
||||
preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hello,\n\nThis is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM with Dr. Smith.\n\nLocation: 123 Medical Plaza, Suite 4\n\nNeed to reschedule? Reply to this email or call (555) 010-7878.\n\nSee you Tuesday!\nDr. Smith's office",
|
||||
),
|
||||
messageId: '<appt-reminder-dr-smith@drsmith.example>',
|
||||
},
|
||||
|
||||
// ── Sent ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-6',
|
||||
@@ -163,16 +519,15 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true },
|
||||
size: 2100,
|
||||
receivedAt: demoDate(-1, -4),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [USER],
|
||||
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
subject: 'Updated Requirements Document',
|
||||
sentAt: demoDate(-1, -4),
|
||||
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
|
||||
preview: "Hi Alice, I've updated the requirements document with the changes we discussed...",
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
|
||||
},
|
||||
...textOnly(
|
||||
"Hi Alice,\n\nI've updated the requirements document with the changes we discussed in yesterday's meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User",
|
||||
),
|
||||
messageId: '<sent-1@example.com>',
|
||||
},
|
||||
{
|
||||
@@ -182,18 +537,37 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true },
|
||||
size: 1800,
|
||||
receivedAt: demoDate(-4, -2),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [USER],
|
||||
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||
subject: 'Re: Design Feedback',
|
||||
sentAt: demoDate(-4, -2),
|
||||
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
|
||||
},
|
||||
...textOnly(
|
||||
"Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet's go with Option B for the navigation.\n\nBest,\nDemo User",
|
||||
),
|
||||
messageId: '<sent-2@example.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-sent-mom',
|
||||
threadId: 'demo-thread-mom',
|
||||
mailboxIds: { 'demo-mailbox-sent': true },
|
||||
keywords: { $seen: true },
|
||||
size: 1400,
|
||||
receivedAt: demoDate(0, -2, -10),
|
||||
from: [USER],
|
||||
to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
|
||||
subject: 'Re: when are you coming home?',
|
||||
sentAt: demoDate(0, -2, -10),
|
||||
preview: "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.",
|
||||
),
|
||||
messageId: '<re-mom-1@example.com>',
|
||||
inReplyTo: ['<5a8c-mom@example.com>'],
|
||||
references: ['<5a8c-mom@example.com>'],
|
||||
},
|
||||
|
||||
// ── Drafts ──────────────────────────────────────────────────
|
||||
{
|
||||
@@ -203,18 +577,35 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true, $draft: true },
|
||||
size: 900,
|
||||
receivedAt: demoDate(0, -1),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [USER],
|
||||
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
subject: 'Meeting Notes - Draft',
|
||||
sentAt: demoDate(0, -1),
|
||||
preview: 'Here are the notes from today\'s standup...',
|
||||
preview: "Here are the notes from today's standup...",
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
|
||||
},
|
||||
...textOnly(
|
||||
"Here are the notes from today's standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ",
|
||||
),
|
||||
messageId: '<draft-1@example.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-draft-recruiter',
|
||||
threadId: 'demo-thread-draft-recruiter',
|
||||
mailboxIds: { 'demo-mailbox-drafts': true },
|
||||
keywords: { $seen: true, $draft: true },
|
||||
size: 720,
|
||||
receivedAt: demoDate(0, -8),
|
||||
from: [USER],
|
||||
to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
||||
subject: 'Re: Senior role - Distributed Systems',
|
||||
sentAt: demoDate(0, -8),
|
||||
preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi Jennifer,\n\nThanks for reaching out. I'm not actively looking, but the role sounds interesting enough that I'd be open to a quick call. A few questions before we set something up:\n\n- ",
|
||||
),
|
||||
messageId: '<draft-recruiter@example.com>',
|
||||
},
|
||||
|
||||
// ── Trash ───────────────────────────────────────────────────
|
||||
{
|
||||
@@ -225,15 +616,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 15200,
|
||||
receivedAt: demoDate(-5, -3),
|
||||
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: '🎉 Flash Sale: 50% Off Everything!',
|
||||
sentAt: demoDate(-5, -3),
|
||||
preview: 'Limited time offer! Get 50% off all items in our store...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
|
||||
},
|
||||
...textOnly(
|
||||
'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.',
|
||||
),
|
||||
messageId: '<promo-1@promostore.example>',
|
||||
},
|
||||
{
|
||||
@@ -244,15 +634,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 2300,
|
||||
receivedAt: demoDate(-7, 0),
|
||||
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your password was changed',
|
||||
sentAt: demoDate(-7, 0),
|
||||
preview: 'Your account password was successfully changed on...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
|
||||
},
|
||||
...textOnly(
|
||||
'Your account password was successfully changed. If you did not make this change, please contact support immediately.',
|
||||
),
|
||||
messageId: '<notification-1@service.example>',
|
||||
},
|
||||
|
||||
@@ -265,15 +654,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 4500,
|
||||
receivedAt: demoDate(-2, -7),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: '[Project] Sprint Planning Agenda',
|
||||
sentAt: demoDate(-2, -7),
|
||||
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
|
||||
preview: "Here's the agenda for next week's sprint planning session...",
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
|
||||
},
|
||||
...textOnly(
|
||||
"Hi team,\n\nHere's the agenda for next week's sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice",
|
||||
),
|
||||
messageId: '<project-1@example.com>',
|
||||
},
|
||||
{
|
||||
@@ -284,17 +672,37 @@ export function createDemoEmails(): Email[] {
|
||||
size: 3200,
|
||||
receivedAt: demoDate(0, -8),
|
||||
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: '[Project] API Rate Limiting Discussion',
|
||||
sentAt: demoDate(0, -8),
|
||||
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
|
||||
preview: "I've been thinking about our rate limiting approach and wanted to propose a few changes...",
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob' },
|
||||
},
|
||||
...textOnly(
|
||||
"Hey,\n\nI've been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob",
|
||||
),
|
||||
messageId: '<project-2@example.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-roadmap',
|
||||
threadId: 'demo-thread-roadmap',
|
||||
mailboxIds: { 'demo-mailbox-projects': true },
|
||||
keywords: {},
|
||||
size: 4900,
|
||||
receivedAt: demoDate(-1, -15),
|
||||
from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }],
|
||||
to: [USER, { name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'James Miller', email: 'james.miller@company.example' }],
|
||||
subject: '[Project] Q1 2026 roadmap - first cut',
|
||||
sentAt: demoDate(-1, -15),
|
||||
preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...',
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
"Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting - I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM",
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' },
|
||||
],
|
||||
messageId: '<roadmap-q1-2026@company.example>',
|
||||
},
|
||||
|
||||
// ── Archive ─────────────────────────────────────────────────
|
||||
{
|
||||
@@ -304,18 +712,35 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true },
|
||||
size: 2600,
|
||||
receivedAt: demoDate(-14, -6),
|
||||
from: [{ name: 'HR Department', email: 'hr@company.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [{ name: 'Maria Lopez', email: 'maria.lopez@company.example' }],
|
||||
to: [USER],
|
||||
subject: 'Updated PTO Policy - Effective January 1',
|
||||
sentAt: demoDate(-14, -6),
|
||||
preview: 'Please review the updated PTO policy that takes effect January 1st...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
|
||||
},
|
||||
...textOnly(
|
||||
'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria - People Ops',
|
||||
),
|
||||
messageId: '<hr-policy-1@company.example>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-archive-support',
|
||||
threadId: 'demo-thread-archive-support',
|
||||
mailboxIds: { 'demo-mailbox-archive': true },
|
||||
keywords: { $seen: true },
|
||||
size: 3400,
|
||||
receivedAt: demoDate(-21, -4),
|
||||
from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }],
|
||||
to: [USER],
|
||||
subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases',
|
||||
sentAt: demoDate(-21, -4),
|
||||
preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi,\n\nThanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire - Fastmail Support",
|
||||
),
|
||||
messageId: '<ticket-438201-resolved@fastmail.com>',
|
||||
},
|
||||
|
||||
// ── Receipts ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -325,17 +750,37 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true },
|
||||
size: 5200,
|
||||
receivedAt: demoDate(-3, -12),
|
||||
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Payment Receipt - Invoice #INV-2024-1042',
|
||||
from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
|
||||
to: [USER],
|
||||
subject: 'Invoice #INV-2024-1042 - €49.99 (paid)',
|
||||
sentAt: demoDate(-3, -12),
|
||||
preview: 'Your payment of $49.99 has been processed successfully...',
|
||||
preview: 'Your payment of €49.99 has been processed successfully...',
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
'Payment Confirmation\n\nAmount: €49.99\nDate: 3 days ago\nInvoice: INV-2024-1042\nService: CX22 dedicated (Helsinki, monthly)\n\nThank you for your payment.',
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-8', blobId: 'demo-blob-att-8', size: 28000, name: 'INV-2024-1042.pdf', type: 'application/pdf' },
|
||||
],
|
||||
messageId: '<receipt-1@hetzner.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-receipts-domain',
|
||||
threadId: 'demo-thread-receipts-domain',
|
||||
mailboxIds: { 'demo-mailbox-receipts': true },
|
||||
keywords: { $seen: true },
|
||||
size: 3100,
|
||||
receivedAt: demoDate(-9, -8),
|
||||
from: [{ name: 'Porkbun', email: 'support@porkbun.com' }],
|
||||
to: [USER],
|
||||
subject: 'Renewal confirmation - example.com (1 year)',
|
||||
sentAt: demoDate(-9, -8),
|
||||
preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
|
||||
},
|
||||
messageId: '<receipt-1@cloudprovider.example>',
|
||||
...textOnly(
|
||||
"Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun",
|
||||
),
|
||||
messageId: '<renewal-example.com@porkbun.com>',
|
||||
},
|
||||
|
||||
// ── Spam ────────────────────────────────────────────────────
|
||||
@@ -347,16 +792,51 @@ export function createDemoEmails(): Email[] {
|
||||
size: 8900,
|
||||
receivedAt: demoDate(-1, -9),
|
||||
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'Congratulations! You Won $1,000,000!!!',
|
||||
sentAt: demoDate(-1, -9),
|
||||
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
|
||||
},
|
||||
...textOnly(
|
||||
'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]',
|
||||
),
|
||||
messageId: '<spam-1@totallylegit.example>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-spam-phish',
|
||||
threadId: 'demo-thread-spam-phish',
|
||||
mailboxIds: { 'demo-mailbox-junk': true },
|
||||
keywords: {},
|
||||
size: 4600,
|
||||
receivedAt: demoDate(-2, -3),
|
||||
from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }],
|
||||
to: [USER],
|
||||
subject: 'URGENT: Unusual activity on your account - verify within 24 hours',
|
||||
sentAt: demoDate(-2, -3),
|
||||
preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo - never click links like this in real life.]",
|
||||
),
|
||||
messageId: '<phish-1@secur1ty-bank.example>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-spam-crypto',
|
||||
threadId: 'demo-thread-spam-crypto',
|
||||
mailboxIds: { 'demo-mailbox-junk': true },
|
||||
keywords: {},
|
||||
size: 6800,
|
||||
receivedAt: demoDate(-3, -19),
|
||||
from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }],
|
||||
to: [USER],
|
||||
subject: '🚀 The coin Elon won\'t tell you about - 1000x potential',
|
||||
sentAt: demoDate(-3, -19),
|
||||
preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier. Limited spots. No experience needed.\n\n[Demo spam.]',
|
||||
),
|
||||
messageId: '<spam-crypto@cryptogrowth.example>',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@ import type { Mailbox } from '@/lib/jmap/types';
|
||||
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
|
||||
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
|
||||
|
||||
// Counts must stay in sync with createDemoEmails() in fixtures/emails.ts.
|
||||
export function createDemoMailboxes(): Mailbox[] {
|
||||
return [
|
||||
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 22, unreadEmails: 13, totalThreads: 20, unreadThreads: 12, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 3, totalThreads: 3, unreadThreads: 3, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 3, unreadEmails: 2, totalThreads: 3, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
} as const;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) =>
|
||||
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
|
||||
);
|
||||
}
|
||||
|
||||
export function plainTextToComposerBody(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
@@ -58,24 +58,39 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature with stricter rules
|
||||
* Only allows basic formatting, no external resources
|
||||
* Allows basic formatting plus <img> for company logos
|
||||
*/
|
||||
export const SIGNATURE_SANITIZE_CONFIG = {
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class'],
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'],
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature for storage and display
|
||||
* Sanitize HTML signature for storage and display.
|
||||
* img src is restricted to https: or base64-embedded raster data: URIs
|
||||
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
|
||||
* bytes inside a data: URI. Images with a disallowed src are removed
|
||||
* entirely so they don't render as broken-image icons.
|
||||
* @param html - User-provided HTML signature
|
||||
* @returns Sanitized signature (no scripts, no external resources)
|
||||
*/
|
||||
export function sanitizeSignatureHtml(html: string): string {
|
||||
if (!html?.trim()) return '';
|
||||
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName !== 'IMG') return;
|
||||
const src = node.getAttribute('src');
|
||||
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
try {
|
||||
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
|
||||
} finally {
|
||||
DOMPurify.removeAllHooks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+19
-3
@@ -2089,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();
|
||||
@@ -2185,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", {
|
||||
@@ -2197,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 {
|
||||
@@ -2207,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"]);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
type SameSite = 'lax' | 'none' | 'strict';
|
||||
|
||||
export function getCookieOptions() {
|
||||
const sameSite = configManager.get<SameSite>('cookieSameSite', 'lax');
|
||||
const secure = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: (sameSite === 'none' || process.env.NODE_ENV === 'production');
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: COOKIE_SAME_SITE,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface ParsedMailto {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
const MAX_SUBJECT_LENGTH = 998;
|
||||
const MAX_BODY_LENGTH = 64 * 1024;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
|
||||
function stripControlChars(value: string): string {
|
||||
return value.replace(CONTROL_CHARS, "");
|
||||
}
|
||||
|
||||
function stripBodyControlChars(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
return stripControlChars(value)
|
||||
.split(",")
|
||||
.map((recipient) => recipient.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
type QueryParam = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function getQueryValue(searchParams: QueryParam[], key: string): string {
|
||||
const values: string[] = [];
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
for (const { key: paramKey, value } of searchParams) {
|
||||
if (paramKey.toLowerCase() === lowerKey) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(",");
|
||||
}
|
||||
|
||||
function decodePathname(pathname: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(pathname || "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeQueryPart(value: string): string | null {
|
||||
try {
|
||||
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
|
||||
// encoding, a literal '+' is part of the value and must not become space.
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuery(query: string): QueryParam[] | null {
|
||||
if (!query) return [];
|
||||
|
||||
const params: QueryParam[] = [];
|
||||
for (const part of query.split("&")) {
|
||||
if (!part) continue;
|
||||
const separatorIndex = part.indexOf("=");
|
||||
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
|
||||
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
|
||||
const key = decodeQueryPart(rawKey);
|
||||
const value = decodeQueryPart(rawValue);
|
||||
if (key === null || value === null) return null;
|
||||
params.push({ key, value });
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function parseMailto(raw: string): ParsedMailto | null {
|
||||
if (!raw.toLowerCase().startsWith("mailto:")) return null;
|
||||
|
||||
const addressAndQuery = raw.slice("mailto:".length);
|
||||
const queryIndex = addressAndQuery.indexOf("?");
|
||||
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
|
||||
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
|
||||
|
||||
const decodedPathname = decodePathname(rawPathname);
|
||||
if (decodedPathname === null) return null;
|
||||
const searchParams = parseQuery(rawQuery);
|
||||
if (searchParams === null) return null;
|
||||
|
||||
const to = [
|
||||
...splitRecipients(decodedPathname),
|
||||
...splitRecipients(getQueryValue(searchParams, "to")),
|
||||
].slice(0, MAX_RECIPIENTS);
|
||||
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
|
||||
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
|
||||
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
|
||||
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user