diff --git a/.env.dev.example b/.env.dev.example index efc75a09..d26b9f14 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -39,6 +39,16 @@ SETTINGS_SYNC_ENABLED=true LOG_FORMAT=text LOG_LEVEL=debug +# ============================================================================= +# Plugin Development +# ============================================================================= + +# Load plugins from a directory on disk instead of installing them as ZIPs. +# Each immediate subfolder is one plugin and needs a manifest.json. When the +# manifest's entrypoint exists under src/, it's bundled on demand with esbuild, +# so you can edit sources and just refresh the browser. +# PLUGIN_DEV_DIR=../my-plugins + # ============================================================================= # Login Page Customization (optional) # ============================================================================= diff --git a/.env.example b/.env.example index 6036141b..065c9f92 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Access-Control-Allow-Origin header, or browser requests will be blocked. # ALLOW_CUSTOM_JMAP_ENDPOINT=true +# Offer several JMAP servers on the login form. JSON array; each entry needs +# id, label, and url. "domains" and a per-server "oauth" block are optional. +# Prefer configuring this from the admin dashboard - the env form exists for +# stateless deployments. +# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}] + +# Pick the server automatically from the domain of the address the user types, +# matching against each entry's "domains" list. Default: false. +# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true + # ============================================================================= # Stalwart Mail Server Integration # ============================================================================= @@ -59,6 +69,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com # OAuth issuer's public hostname resolves to an internal IP from this server. # OAUTH_ALLOW_PRIVATE_ENDPOINTS=true +# Replace the scopes requested at authorization. Space-separated. Leave unset +# to use the defaults the client already asks for. +# OAUTH_SCOPES=openid email profile offline_access + +# Append scopes instead of replacing them. Use this when your IdP needs one +# extra scope and you don't want to restate the defaults. +# OAUTH_EXTRA_SCOPES=groups + +# Send the user straight to the identity provider, skipping the login form. +# Intended for embedded deployments where the parent app already authenticated +# them. Default: false. +# AUTO_SSO_ENABLED=true + # ============================================================================= # Session & Security # ============================================================================= @@ -132,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com # so the instance id and consent choice survive upgrades. # TELEMETRY_DATA_DIR=./data/telemetry +# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset. +# BULWARK_TELEMETRY_DISABLED=1 + +# Let heartbeats reach a private/loopback address. Off by default as an SSRF +# guard; only useful when running a collector locally during development. +# BULWARK_TELEMETRY_ALLOW_PRIVATE=1 + +# Report a fixed Stalwart version instead of probing the JMAP server's Server +# header. Useful when a proxy strips that header. +# STALWART_VERSION=0.16.0 + # ============================================================================= # Server Listen Address # ============================================================================= @@ -197,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Should match your app's main background color. Default: #ffffff # PWA_BACKGROUND_COLOR=#ffffff +# Screenshots shown in the browser's install prompt. Absolute URLs or paths +# relative to public/. Both are optional; per-domain overrides are available +# through DOMAIN_BRANDING. +# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png +# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png + # --------------------------------------------------------------------------- # Logos # --------------------------------------------------------------------------- @@ -234,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page. LOGIN_WEBSITE_URL=https://bulwarkmail.org +# Cap the login logo's rendered size. Any CSS length ("120px", "8rem"). +# Unset means the logo renders at its natural size. +# LOGIN_LOGO_MAX_HEIGHT=96px +# LOGIN_LOGO_MAX_WIDTH=320px + +# Hide parts of the login page. All default to true. +# Turn the heading and subtitle off when the logo already reads as the brand. +# LOGIN_SHOW_HEADING=false +# LOGIN_SHOW_SUBTITLE=false +# +# Hide the optional TOTP field. A server that requires TOTP (totp_required) +# still shows it regardless of this setting. +# LOGIN_SHOW_TOTP=false +# +# Hide the version number, so it isn't disclosed to unauthenticated visitors. +# LOGIN_SHOW_VERSION=false + # --------------------------------------------------------------------------- # Per-domain branding overrides (optional) # --------------------------------------------------------------------------- @@ -266,6 +323,108 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # your own directory (e.g. http://localhost:3001 for local development). # EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org +# ============================================================================= +# Admin Dashboard Access +# ============================================================================= + +# Bootstrap password for the admin dashboard. Read only when admin.json does +# not already exist; the app hashes it, writes admin.json, and logs a warning +# telling you to remove this variable. Without it (and without the setup +# wizard) the admin dashboard stays disabled. +# Accepts a plaintext password or an existing hash. +# ADMIN_PASSWORD=change-me + +# Admin session lifetime in seconds. Default: 3600 (1 hour). +# ADMIN_SESSION_TTL=3600 + +# How many trusted reverse proxies sit in front of the app. The client IP is +# taken that many entries from the right of X-Forwarded-For, so an attacker +# can't spoof it by prepending values. Default: 1. +# TRUSTED_PROXY_DEPTH=2 + +# Allow search engines to index the app (robots.txt / noindex). Default: false. +# SEARCH_ENGINE_INDEXING=true + +# ============================================================================= +# Cookies, Embedding & Reverse Proxies +# ============================================================================= + +# SameSite attribute for session cookies: lax (default), strict, or none. +# Embedding the app cross-origin in an iframe requires "none". +# COOKIE_SAME_SITE=none + +# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or +# COOKIE_SAME_SITE=none. Set to false only for local HTTP development. +# COOKIE_SECURE=false + +# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none', +# which blocks all framing. Space-separate multiple origins. +# ALLOWED_FRAME_ANCESTORS=https://portal.example.com + +# Origin of the parent page when embedded, used for postMessage handshakes. +# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com + +# ============================================================================= +# Update Check +# ============================================================================= + +# The app periodically checks for new releases and shows a notice. Set to +# "off" (or false/0/no) to disable the check entirely. +# BULWARK_UPDATE_CHECK=off + +# Override the endpoint it checks. Takes priority over the on-disk state file. +# An explicit empty value also disables the check. +# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json + +# Where the check stores its state. Default: ./data/version-check +# VERSION_CHECK_DATA_DIR=./data/version-check + +# ============================================================================= +# Translation Proxy (optional) +# ============================================================================= + +# /api/translate defaults to the public MyMemory API, which needs no setup. +# Point it at a LibreTranslate instance instead to keep message text on +# infrastructure you control. LibreTranslate also auto-detects the source +# language natively. +# LIBRETRANSLATE_URL=https://libretranslate.example.com +# LIBRETRANSLATE_API_KEY= + +# ============================================================================= +# Web Push +# ============================================================================= + +# Push notifications go through a hosted relay so self-hosters don't need +# their own VAPID keys and Firebase project. Point this at your own relay to +# avoid the default. Build-time variable. +# Default: https://notifications.relay.bulwarkmail.org +# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com + +# ============================================================================= +# Demo Mode +# ============================================================================= + +# Serve fixture data instead of talking to a mail server. Default: false. +# DEMO_MODE=true + +# ============================================================================= +# Stalwart Impersonation (advanced) +# ============================================================================= + +# Lets a trusted platform mint a JWT that logs a user in without their +# password, using a Stalwart master account. Intended for embedded +# deployments where an outer platform already authenticated the user. +# +# SECURITY: this grants sign-in as any mailbox on the server. The endpoint +# returns 404 unless all three required variables below are set, so leaving +# them unset keeps the feature fully off. Treat the secret and the master +# password as you would a root credential. +# +# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters +# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com +# BULWARK_STALWART_MASTER_PASSWORD= # required +# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail" + # ============================================================================= # Internationalization # ============================================================================= @@ -274,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # # Fallback UI locale used when the visitor's Accept-Language header does not # match any supported locale. Defaults to "en". -# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh +# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, +# pt, ro, ru, sk, tr, uk, zh +# An unsupported value falls back to "en". # NEXT_PUBLIC_DEFAULT_LOCALE=tr # Locale prefix mode for URLs. Recommended "always" when proxying under a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d941e32..921cf0f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,13 +10,13 @@ # Contributing to Bulwark Webmail -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. +We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them. -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. +If that sounds like your kind of project, we'd love the help. -## Join the Community +## 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. +You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this. - **Get support** - real-time help with development hurdles - **Share ideas** - feature suggestions, design feedback, doc improvements @@ -26,9 +26,9 @@ You don't need to be an expert to contribute. Whether you're setting up your dev --- -## Getting Started +## Getting started -### Development Setup +### Development setup 1. **Fork and clone** the repository: @@ -46,16 +46,22 @@ You don't need to be an expert to contribute. Whether you're setting up your dev 3. **Set up environment**: ```bash - cp .env.example .env.local - # Edit .env.local with your JMAP server URL + cp .env.dev.example .env.local ``` + This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can + develop without a mail server. Log in with any username and password. To work + against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`. + 4. **Start development server**: + ```bash npm run dev ``` -### Code Quality + Then open http://localhost:3000. + +### Code quality Before submitting a pull request, ensure your code passes all checks: @@ -72,7 +78,20 @@ npm run lint:fix These checks run automatically on commit via Husky pre-commit hooks. -## Code Style Guidelines +### Testing + +| Suite | Command | What it covers | +| ---------------- | -------------------------- | ------------------------------------------------------------------ | +| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code | +| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English | +| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker | +| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` | + +Run a single unit test file with `npx vitest run lib/__tests__/.test.ts`, or `npx vitest` to watch. + +The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there. + +## Code style guidelines ### TypeScript @@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks. - Avoid `any` types when possible - Use meaningful variable and function names -### React Components +### React components - Use functional components with hooks - Keep components focused and single-purpose @@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks. ## Internationalization (i18n) -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). +This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh). + +Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities. ### Rules @@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour router.push(`/${params.locale}/settings`); ``` -## Pull Request Process +### Adding a new locale -### Before Submitting +Registering a new locale takes edits in four places: + +1. `locales//common.json` - copy `locales/en/common.json` and translate +2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES` +3. `i18n/request.ts` - add a `case` to the static-import switch +4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx` + +For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`. + +Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English. + +## Pull request process + +### Before submitting 1. **Create a feature branch**: @@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour 2. **Make your changes** following the code style guidelines -3. **Test your changes** thoroughly +3. **Test your changes** thoroughly, and add unit tests for new logic 4. **Update translations** if you added user-facing text 5. **Run all checks**: ```bash - npm run typecheck && npm run lint + npm run typecheck && npm run lint && npx vitest run ``` ### Submitting @@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour - Screenshots for UI changes - Reference to any related issues -### Commit Message Convention +### Commit message convention Follow the conventional commits format: @@ -177,25 +211,37 @@ fix: resolve attachment download issue docs: update README with keyboard shortcuts ``` -## Project Structure +## Project structure ``` webmail/ -├── app/ # Next.js App Router pages -│ └── [locale]/ # Locale-aware routing -├── components/ # React components -│ ├── email/ # Email-related components -│ ├── layout/ # Layout components -│ ├── settings/ # Settings components -│ └── ui/ # Reusable UI components -├── contexts/ # React contexts -├── hooks/ # Custom React hooks -├── lib/ # Utilities and libraries -│ └── jmap/ # JMAP client implementation -├── locales/ # Translation files -│ ├── en/ # English translations -│ └── fr/ # French translations -└── stores/ # Zustand state stores +├── app/ # Next.js App Router +│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings) +│ ├── (main)/admin/ # Admin dashboard +│ ├── (main)/setup/ # First-launch setup wizard +│ ├── (sandbox)/ # Isolated plugin sandbox routes +│ └── api/ # Route handlers (auth, admin, jmap, caldav, …) +├── components/ # React components +│ ├── email/ # Email list, viewer, composer +│ ├── calendar/ contacts/ files/ filters/ templates/ +│ ├── layout/ # Sidebar, shell, navigation +│ ├── settings/ # Settings panels +│ ├── plugins/ # Plugin host UI +│ └── ui/ # Reusable primitives +├── contexts/ # React contexts +├── hooks/ # Custom React hooks +├── i18n/ # next-intl routing, locale detection, RTL direction +├── lib/ # Utilities and libraries +│ ├── jmap/ # JMAP client implementation +│ ├── stalwart/ # Stalwart-specific admin/API helpers +│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows +│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening +│ └── __tests__/ # Vitest unit tests +├── locales/ # Translation files, one directory per locale +├── stores/ # Zustand state stores +├── public/ # Static assets and branding +├── e2e/ # Playwright smoke tests (against `npm run dev`) +└── integration/ # Dockerized Stalwart + Playwright suite ``` ## Security diff --git a/FEATURES.md b/FEATURES.md index 1043c7c1..2ae105f0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,145 +2,149 @@ ## Mail -- 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 – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account -- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message -- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes -- 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 – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning -- Scheduled send and configurable send delay +- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables +- Gmail-style threading, expanded inline, with a conversation toggle you can switch off +- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account. +- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from. +- Search runs across all unified views; the per-role mailboxes add the full filter panel on top +- Three mail layouts: split three-pane, focused list, or reading pane at the bottom +- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies +- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it. +- Scheduled send, plus a configurable delay before anything leaves the outbox - Read receipts (MDN, RFC 8098) -- Editable, layout-preserving quote island when replying -- 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 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 +- Quoted text lands in an editable island that keeps the original layout +- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries +- Multi-select for batch archive, delete, move, and tag +- Archive directly, by year, or by month +- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them +- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree +- Each tag can be configured to show always, only when there are unread mails or always be hidden +- Star or unstar, with a configurable mark-as-read delay +- Large mailboxes scroll virtually, and the first page of mail prefetches at login +- Quick reply, hover actions, favicon-based sender avatars, recipient popovers +- Plain-text composer mode and Reply-To +- The signature sits above or below the quoted text, per identity +- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it. +- Import `.eml` files from the 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 -- Browser history sync for back/forward navigation +- Folders take an icon, nest, and show counts in the sidebar +- Print from the viewer +- Browser back and forward move through mail history ## Calendar -- Month, week, day, and agenda views with a mini-calendar sidebar and task list -- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap -- Recurring events with scoped edit/delete (this / this and following / all) -- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment -- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import -- iCalendar import with preview, bulk create, and UID deduplication -- iCal / webcal subscriptions with editing and batch import -- Auto-generated birthday calendar from contacts -- Virtual locations (video conference URLs) as first-class event fields -- Task management with due dates, priority, and completion status -- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors -- Week numbers, event hover preview, notifications with sound picker -- Real-time sync via JMAP push +- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar +- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes. +- Recurring events edit and delete by scope: this occurrence, this and following, or all +- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment +- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message +- iCalendar import previews first, then bulk-creates, deduplicating on UID +- iCal / webcal subscriptions, editable, with batch import +- A birthday calendar generated from your contacts +- Virtual locations (video-conference URLs) are first-class event fields +- Tasks with due dates, priority, and completion status +- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer +- Week numbers, hover preview, notifications with a sound picker +- JMAP push keeps everything in sync ## Contacts -- JMAP sync (RFC 9553 / 9610) with local fallback -- Multiple address books with drag-and-drop between books -- Contact groups with member management -- vCard import/export (RFC 6350) with duplicate detection -- Trusted senders stored in a dedicated JMAP address book -- Autocomplete in the composer (To / Cc / Bcc) +- JMAP sync (RFC 9553 / 9610), falling back to local storage +- Several address books, with drag-and-drop between them +- Groups with member management +- vCard import/export (RFC 6350) that flags duplicates +- Trusted senders live in their own JMAP address book +- Autocomplete on To, Cc, and Bcc -## Filters & Templates +## Filters & templates -- Server-side filters via JMAP Sieve Scripts (RFC 9661) -- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…) -- Preserves rules authored in other clients +- Server-side filters as JMAP Sieve Scripts (RFC 9661) +- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard +- Rules written in other clients survive the round-trip - Raw Sieve editor with syntax validation -- Vacation responder with date range scheduling -- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …) +- A vacation responder you can schedule to a date range +- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …) ## Files -- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load -- Streamed WebDAV PUT upload and folder upload with progress tracking -- Dynamic upload limits based on server configuration -- Grid and list views with sorting by name, size, or date -- Previews for images, text, audio, and video -- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files -- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you +- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load. +- Streamed WebDAV PUT upload, whole folders included, with progress +- Upload limits follow the server's own configuration +- Grid or list, sorted by name, size, or date +- Preview images, text, audio, and video +- Cut, copy, paste, duplicate; favorites; recent files +- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me". -## Security & Privacy +## Security & privacy -- External content blocked by default, with a trusted senders list -- HTML sanitization via DOMPurify -- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation -- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail -- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments +- External content stays blocked until you say otherwise, and trusted senders are remembered +- HTML sanitized through DOMPurify +- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account. +- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail +- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments - TOTP two-factor authentication -- Account security panel for password and 2FA management via the Stalwart admin API -- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie -- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention -- Plugin hardening with dangerous-pattern detection and admin approval +- Password and 2FA management through the Stalwart admin API +- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie +- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention +- Plugins are scanned for dangerous patterns and need admin approval - Newsletter unsubscribe (RFC 2369) ## Interface -- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns -- Dark and light themes with intelligent email color transformation -- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips -- Responsive desktop, tablet, and mobile layouts +- Split three-pane, focused list, or bottom reading pane, columns resizable +- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background. +- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants. +- Layouts for desktop, tablet, and mobile - Full keyboard navigation -- Drag-and-drop email organization and tag assignment -- Interactive guided tour for new users -- Right-click context menus, toast notifications with undo -- Customizable toolbar position, favicon, and login branding -- Pinnable sidebar apps with drag-and-drop reordering -- Encrypted settings sync across devices +- Drag and drop to organize mail and assign tags +- A guided tour for first-time users +- Right-click menus, and toasts that offer an undo +- Toolbar position, favicon, and login branding are configurable +- Sidebar apps pin and reorder by drag +- Settings sync between devices, encrypted - Storage quota display -- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions +- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions ## Internationalization -19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 +24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文 -Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. +- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically +- The browser's `Accept-Language` picks the first language, and the choice persists per user +- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix -## Identity & Multi-Account +## Identity & multi-account -- 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 -- Configurable signature position (above or below quoted text) -- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions +- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit. +- An account switcher showing connection status, and a default account +- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list +- Signature above or below the quoted text +- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context - Shared folders across accounts -- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync -- Multiple JMAP servers per deployment with optional auto-pick by email domain -- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) +- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step. +- Several JMAP servers per deployment, optionally auto-picked by email domain +- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it -## Admin & Extensibility +## Admin & extensibility -- 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 -- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle -- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) -- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts -- Admin toggle for search-engine indexing (`robots.txt` / `noindex`) -- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, 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`); install/uninstall restricted to the admin dashboard -- Bundled plugins including Jitsi Meet calendar integration +- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched. +- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page +- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle. +- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps. +- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts +- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`) +- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement +- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins` +- Themes upload as ZIP bundles, and admins can enforce one +- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard. +- Bundled plugins, including Jitsi Meet for the calendar ## Operations -- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots -- 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-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – 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 +- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain +- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed +- Structured logging (`text` or `json`) with per-category levels +- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles. +- Docker images on GHCR, for release (`main`) and development (`dev`) +- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy +- Demo mode runs on fixture data, no mail server required diff --git a/README.md b/README.md index de76d879..e7175763 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # Bulwark Webmail -A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol. +A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) @@ -20,12 +20,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar ## Installer -New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container. - - - - Setup wizard - +Since **1.6.4**, a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container. Point a browser at the running container and the wizard guides you through: @@ -70,27 +65,27 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM Settings -Light mode – full theme support with intelligent color transformation for HTML emails. +Light mode – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable. Settings – appearance, identities, filters, templates, security, and more. -## Overview +## What Bulwark includes -Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: +Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting: - **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates - **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions - **Contacts** – multiple address books, groups, vCard import/export - **Files** – Stalwart's JMAP FileNode storage with previews and folder upload -Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard. +They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four. Full feature list: **[FEATURES.md](FEATURES.md)**. --- -## Quick Start +## Quick start ### Docker @@ -104,9 +99,9 @@ Or with Docker Compose: 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). +On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration). -### From Source +### From source ```bash git clone https://github.com/bulwarkmail/webmail.git @@ -119,16 +114,20 @@ npm run build && npm start ### Development ```bash -npm run dev # Dev server with a mock JMAP server +cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed + +npm run dev # Dev server npm run typecheck npm run lint +npx vitest run # Unit tests +npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md) ``` ## 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. +Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI. -All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: +Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`: ```env # Optional – overrides whatever the wizard writes @@ -151,13 +150,28 @@ PORT=3000 ```env OAUTH_ENABLED=true +OAUTH_ONLY=true # hide the username/password form entirely OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret OAUTH_ISSUER_URL= # optional, for external IdPs +OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint +OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses ``` -Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. +Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP. + + + +
+Anonymous telemetry + +```env +BULWARK_TELEMETRY=on # opt-in; off by default +TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume +``` + +Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp +
+Default UI locale + +The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`): + +```env +NEXT_PUBLIC_DEFAULT_LOCALE=de +``` + +Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`. + +Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own: + +```bash +docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail . +``` + +
+
Subpath / reverse proxy mount @@ -271,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail . ``` -Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly. +Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
-## Keyboard Shortcuts +## Keyboard shortcuts -| Key | Action | -| ------------- | ----------------------- | -| `j` / `k` | Navigate between emails | -| `Enter` / `o` | Open email | -| `Esc` | Close / deselect | -| `c` | Compose | -| `r` / `R` | Reply / Reply all | -| `f` | Forward | -| `s` | Star | -| `e` | Archive | -| `#` | Delete | -| `/` | Search | -| `?` | Show all shortcuts | +| Key | Action | +| -------------------- | ----------------------- | +| `j` `↓` / `k` `↑` | Navigate between emails | +| `Enter` / `o` | Open email | +| `Esc` | Close / deselect | +| `x` | Expand / collapse thread | +| `c` | Compose | +| `r` / `R` `a` | Reply / Reply all | +| `f` | Forward | +| `s` | Star | +| `e` | Archive | +| `#` / `Del` | Delete | +| `u` / `Shift`+`I` | Mark unread / read | +| `!` | Toggle spam | +| `Ctrl`+`A` | Select all | +| `Shift`+`G` | Refresh | +| `/` | Search | +| `?` | Show all shortcuts | -## Tech Stack +In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker. + +## Tech stack | | | | ------------- | ------------------------------------------------- | -| **Framework** | [Next.js 16](https://nextjs.org/) with App Router | +| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 | | **Language** | TypeScript | | **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) | | **State** | [Zustand](https://zustand-demo.pmnd.rs/) | | **Protocol** | Custom JMAP client (RFC 8620) | +| **Editor** | [Tiptap](https://tiptap.dev/) | | **i18n** | [next-intl](https://next-intl-docs.vercel.app/) | | **Icons** | [Lucide React](https://lucide.dev/) | +| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) | ## Why Stalwart? diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 8c3126af..e91cab21 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast- import { TourProvider } from "@/components/tour/tour-provider"; import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider"; import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; +import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; @@ -39,6 +40,7 @@ export default async function LocaleLayout({ + {children} diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 547b66dc..5f03ea1d 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -34,6 +34,7 @@ import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import { ErrorBoundary, SidebarErrorFallback, @@ -61,7 +62,8 @@ import { isFilePreviewable } from "@/lib/file-preview"; import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity"; +import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; 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"; @@ -77,6 +79,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; +import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import type { QuoteHeader } from "@/lib/plugin-types"; @@ -282,6 +285,7 @@ export default function Home() { toggleStar, setEmailKeywordsLocal, moveToMailbox, + moveToMailboxCrossAware, moveThreadToMailbox, searchEmails, searchQuery, @@ -783,7 +787,15 @@ export default function Home() { // This makes the Pro composer behave like Thunderbird's pop-out window. useEffect(() => { if (!isEmbedded || !showComposer) return; - const replyTo = selectedEmail ? { + // pendingDraft.replyTo, when set, was built by the opener (e.g. + // handleForwardAsAttachment) with intent that must survive the hop into + // the Pro tab - mirrors the same precedence the non-embedded render path + // uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo + // : ...}`). Building fresh from selectedEmail unconditionally here would + // silently drop that intent (e.g. the synthetic message/rfc822 + // attachment "Forward as attachment" stages), falling back to a normal + // quoted forward instead. + const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? { from: selectedEmail.from, replyToAddresses: selectedEmail.replyTo, to: selectedEmail.to, @@ -799,7 +811,7 @@ export default function Home() { quoteHeaderHtml: composerQuoteHeader?.html, quoteHeaderText: composerQuoteHeader?.text, quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote, - } : undefined; + } : undefined); const effectiveMode = pendingDraft?.mode ?? composerMode; const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? ''; @@ -1538,6 +1550,77 @@ export default function Home() { if (isMobile) setActiveView('viewer'); }; + // Forward the original message as a message/rfc822 attachment instead of + // inline-quoted text - e.g. for reporting spam to an upstream gateway + // that expects the raw original as an attachment, or preserving exact + // formatting/headers the recipient needs to see untouched. Reuses the + // same attachment-carry-forward mechanism native Forward already uses + // for a forwarded message's own attachments (see the `attachments` + // useState initializer in email-composer.tsx) - we just add one more + // synthetic entry representing the whole original message, referenced + // by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are + // account-scoped, not per-email). Skips prepareComposerQuoteHeader + // entirely, so the body starts blank instead of quoting the original. + // Takes an explicit `email` (defaulting to selectedEmail), same pattern + // handleDelete uses just below, rather than always reading selectedEmail + // from this closure - callers that just called selectEmail(email) and + // invoke this synchronously in the same tick would otherwise see the + // PRE-update value (the Zustand store updates immediately, but this + // render's selectedEmail closure doesn't until the next render), + // forwarding the previously selected message or no-op'ing on an + // unselected row. See the list context-menu wiring below. + const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => { + if (!email) return; + // Same filename options "Export as .eml" uses (see emailFilenameOptions + // in email-viewer.tsx), so the two actions produce consistent filenames + // for the same message rather than the synthetic attachment silently + // ignoring the user's configured naming template. + const { + emailDownloadTemplate, + filenameSpaceReplacement, + filenameLowercase, + filenameStripDiacritics, + filenameCollapseSeparators, + } = useSettingsStore.getState(); + const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), { + template: emailDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }); + if (!payload) return; + + const ok = await emailHooks.onBeforeForward.intercept({ + originalEmailId: email.id, + originalEmail: emailToReadView(email), + mode: 'forward' as const, + }); + if (!ok) return; + + startFreshComposerSession(); + setPendingDraft({ + to: "", + cc: "", + bcc: "", + subject: payload.subject, + body: "", + showCc: false, + showBcc: false, + selectedIdentityId: null, + subAddressTag: "", + mode: "forward", + draftId: null, + replyTo: { + subject: email.subject, + attachments: [payload.attachment], + }, + }); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }; + const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; @@ -1752,7 +1835,7 @@ export default function Home() { keywords['$pinned'] = true; } - // Same unified-view routing as color tags: write to the email's own + // Same unified-view routing as tags: write to the email's own // account via the login it is reachable through. (#281) const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; @@ -1776,31 +1859,35 @@ export default function Home() { } }; - const handleSetColorTag = async (emailId: string, color: string | null) => { + const handleSetTag = async (emailId: string, tagId: string | null) => { if (!client) return; try { - // Remove any existing label/color tags + // Remove any existing tag keywords const email = emails.find(e => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; - if (color === null) { - // Remove all label/color tags + if (tagId === null) { + // Remove all tag keywords Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { keywords[key] = false; } }); } else { - const jmapKey = `$label:${color}`; - if (keywords[jmapKey]) { - // Toggle off if already active - keywords[jmapKey] = false; + // Both prefixes name the same tag when read, so taking one off has to + // clear whichever spellings are actually set. + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + activeKeys.forEach(key => { + keywords[key] = false; + }); } else { // Add the tag without disturbing others - keywords[jmapKey] = true; + keywords[KEYWORD_PREFIX + tagId] = true; } } @@ -1826,7 +1913,7 @@ export default function Home() { // Refresh tag counts fetchTagCounts(client); } catch (error) { - console.error("Failed to set color tag:", error); + console.error("Failed to set tag:", error); } }; @@ -2399,8 +2486,20 @@ export default function Home() { const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; - const sender = selectedEmail.from?.[0]; - if (!sender?.email) { + // Quick reply follows the same addressing rules as the composer: Reply-To + // over From, and for our own messages in a thread the original recipients + // instead of ourselves (#703). + const ownIdentityEmails = identities.map(i => i.email).filter(Boolean); + const replySource = { + from: selectedEmail.from, + replyToAddresses: selectedEmail.replyTo, + to: selectedEmail.to, + cc: selectedEmail.cc, + }; + const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to + .map(r => r.email) + .filter((email): email is string => Boolean(email)); + if (recipients.length === 0) { throw new Error("No sender email found"); } @@ -2409,14 +2508,21 @@ export default function Home() { // Decide the sending identity and (for domain-catch-all) an optional // header From override that matches the address the message was sent to. + // Our own message keeps the identity it was sent from - the recipients are + // the other party, so resolving from them would send as their address. // 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, - }) + const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails) + ? findDraftIdentityId(identities, selectedEmail.from?.[0]) : null; + const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity + ? null + : selfSentIdentityId + ? { identityId: selfSentIdentityId } + : resolveReplyFrom(identities, { + to: selectedEmail.to, + cc: selectedEmail.cc, + bcc: selectedEmail.bcc, + }); const sendingIdentity = resolved ? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity) : primaryIdentity; @@ -2463,7 +2569,7 @@ export default function Home() { // Send reply with just the body text const result = await sendEmail( client, - [sender.email], + recipients, buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')), finalBody, undefined, @@ -3205,6 +3311,10 @@ export default function Home() { selectEmail(email); handleForward(); }} + onForwardAsAttachment={(email) => { + selectEmail(email); + handleForwardAsAttachment(email); + }} onMarkAsRead={async (email, read) => { if (client) { await markAsRead(client, email.id, read); @@ -3224,12 +3334,12 @@ export default function Home() { onArchive={async (email) => { await handleArchive(email); }} - onSetColorTag={(emailId, color) => { - handleSetColorTag(emailId, color); + onSetTag={(emailId, color) => { + handleSetTag(emailId, color); }} onMoveToMailbox={async (emailId, mailboxId) => { if (client) { - await moveToMailbox(client, emailId, mailboxId); + await moveToMailboxCrossAware(client, emailId, mailboxId); } }} onMarkAsSpam={async (email) => { @@ -3435,6 +3545,7 @@ export default function Home() { onReply={handleReply} onReplyAll={handleReplyAll} onForward={handleForward} + onForwardAsAttachment={handleForwardAsAttachment} onDelete={() => { // Deleting the open message returns to the list (Gmail-style), // not the next email — unless the user turned the setting off. @@ -3448,7 +3559,7 @@ export default function Home() { }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onMarkAsSpam={() => handleMarkAsSpam()} onUndoSpam={() => handleUndoSpam()} onMarkAsRead={async (emailId, read) => { @@ -3499,7 +3610,7 @@ export default function Home() { selectedMailbox={selectedMailbox} onMoveToMailbox={async (mailboxId) => { if (client && selectedEmail) { - await moveToMailbox(client, selectedEmail.id, mailboxId); + await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId); } }} className={isMobile ? "flex-1" : undefined} diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts index 150e5e2a..76c9a37d 100644 --- a/app/api/account/stalwart/jmap/route.ts +++ b/app/api/account/stalwart/jmap/route.ts @@ -55,9 +55,23 @@ export async function POST(request: NextRequest) { logger.error('Stalwart JMAP passthrough redirect error', { error: error.message }); return NextResponse.json({ error: error.message }, { status: 502 }); } + // `fetch failed` from undici is too generic to debug — the real reason + // (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`. + const err = error as Error & { cause?: { code?: string; message?: string } }; logger.error('Stalwart JMAP passthrough error', { - error: error instanceof Error ? error.message : 'Unknown', + error: err?.message ?? 'Unknown', + causeCode: err?.cause?.code, + causeMessage: err?.cause?.message, }); + // The server this process failed to reach is the user's own mail server, + // so the reason is worth surfacing: an opaque 500 leaves operators with + // nothing to act on. + if (err?.cause?.code) { + return NextResponse.json( + { error: `Cannot reach the JMAP server (${err.cause.code})` }, + { status: 502 }, + ); + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 7a5dca04..858ccb8e 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -39,7 +39,8 @@ function impersonationCookieOptions() { * Master-user impersonation via signed JWT. The token carries the target * mailbox; Bulwark verifies the signature, resolves the configured Stalwart * master credentials from env, then mints the same session cookies the - * password-login path produces. The browser is redirected to "/" and the + * password-login path produces. The browser is redirected to "/?impersonated=1" (see + * ImpersonationReconciler, GH #646) and the * SPA hydrates as if the user had just logged in with master@target%master. * * Returns 404 when the feature is not configured so an unconfigured @@ -136,6 +137,6 @@ export async function GET(request: NextRequest) { // when running behind a reverse proxy that doesn't set X-Forwarded-Host. return new NextResponse(null, { status: 303, - headers: { Location: '/' }, + headers: { Location: '/?impersonated=1' }, }); } diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index faec6968..423fb0e8 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -108,7 +108,7 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0), - from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], + from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Willkommen bei Bulwark Webmail!', preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.', @@ -120,78 +120,137 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1), - from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }], + id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1), + from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], - cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }], - subject: 'Project Update - Q1 Review', - preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.', + cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }], + subject: 'Q1 numbers, and the part I want to talk about', + preview: 'The deck is attached. Short version: we land on target, but not the way we planned it.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-003', size: 640, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-004', size: 820, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-003', size: 780, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-004', size: 1600, type: 'text/html' }], bodyValues: { - p1: { value: 'Salut team,\n\nI wanted to share the latest project numbers. We are on track to meet our targets for Q1.\n\nKey highlights:\n- Revenue up 12%\n- New signups increased by 8%\n- Customer satisfaction at 94%\n\nLet me know if you have questions.\n\nCordialement,\nPierre' }, - p2: { value: '

Salut team,

I wanted to share the latest project numbers. We are on track to meet our targets for Q1.

  • Revenue up 12%
  • New signups increased by 8%
  • Customer satisfaction at 94%

Let me know if you have questions.

Cordialement,
Pierre

' }, + p1: { value: 'Hello both,\n\nThe deck is attached. Short version: we land on target, but not the way we planned it.\n\nRevenue: +12% against a +9% forecast. Almost all of it comes from the two enterprise renewals in February, so it is two customers, not a trend.\n\nSignups: +8%, which is under plan. The self-serve funnel loses people at the payment step, and it has done so for three quarters now.\n\nSupport satisfaction: 94% across 1.240 tickets.\n\nI would like twenty minutes on Thursday for the funnel drop-off before we commit to Q2 targets. The rest of the deck can be read offline.\n\nBien à vous,\nPierre' }, + p2: { value: '

Hello both,

The deck is attached. Short version: we land on target, but not the way we planned it.

Revenue+12%forecast +9%
New signups+8%forecast +15%
Support satisfaction94%1.240 tickets

The revenue line is two enterprise renewals in February, so it is two customers, not a trend. The funnel loses people at the payment step and has done so for three quarters now.

I would like twenty minutes on Thursday for the drop-off before we commit to Q2 targets. The rest of the deck can be read offline.

Bien à vous,
Pierre

' }, }, attachments: [ { partId: 'att1', blobId: 'blob-att-001', size: 24500, name: 'Q1-Bericht.pdf', type: 'application/pdf' }, ], }, { - id: 'email-003', threadId: 'thread-003', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(2), + id: 'email-003', threadId: 'thread-003', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2400, receivedAt: daysAgo(2), from: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Pranzo domani?', - preview: 'Ciao! Are you free for lunch tomorrow? I know a great trattoria near the Herengracht.', + subject: 'Lunch tomorrow?', + preview: 'Are you free around 12:30? There is a new place on the Herengracht that does a decent risotto.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-005', size: 180, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-006', size: 260, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-005', size: 240, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-006', size: 320, type: 'text/html' }], bodyValues: { - p1: { value: 'Ciao!\n\nAre you free for lunch tomorrow? I know a great trattoria near the Herengracht. They do an amazing risotto ai funghi porcini.\n\nFammi sapere!\nChiara' }, - p2: { value: '

Ciao!

Are you free for lunch tomorrow? I know a great trattoria near the Herengracht. They do an amazing risotto ai funghi porcini.

Fammi sapere!
Chiara

' }, + p1: { value: 'Are you free around 12:30? There is a new place on the Herengracht that does a decent risotto, which is a low bar in this city, but they clear it.\n\nI have a call at 14:00, so it has to be a short one.\n\nChiara' }, + p2: { value: '

Are you free around 12:30? There is a new place on the Herengracht that does a decent risotto, which is a low bar in this city, but they clear it.

I have a call at 14:00, so it has to be a short one.

Chiara

' }, }, }, { - id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0), - from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], + id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0), + from: [{ name: 'GitHub', email: 'notifications@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[bulwark-webmail] New issue: Add dark mode toggle (#42)', - preview: 'A new issue has been opened by @contributor. It would be great to have a dark mode toggle in the settings panel.', + subject: '[bulwark-webmail] Theme choice is ignored after an OS theme change (#42)', + preview: 'karel-devries opened issue #42: setting the theme to Dark explicitly, then switching the OS to light, drops back to the OS theme on reload.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-007', size: 350, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 500, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-007', size: 620, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 980, type: 'text/html' }], bodyValues: { - p1: { value: 'A new issue has been opened by @contributor.\n\nTitle: Add dark mode toggle\n\nIt would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.\n\n-\nReply to this email directly or view it on GitHub.' }, - p2: { value: '

A new issue has been opened by @contributor.

Add dark mode toggle

It would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.


Reply to this email directly or view it on GitHub.

' }, + p1: { value: '@karel-devries opened issue #42\n\nSteps:\n1. Settings > Appearance > Theme: Dark\n2. Switch the OS to its light theme\n3. Reload the page\n\nExpected: it stays dark, because the choice was explicit.\nActual: it follows the OS again.\n\nThe stored preference survives the reload (I can see it in localStorage), it just is not read before first paint. Firefox 128 on Fedora 41, reproduced in Chromium 133.\n\n-\nReply to this email directly, view it on GitHub, or unsubscribe.' }, + p2: { value: '

@karel-devries opened issue #42

Steps:

  1. Settings › Appearance › Theme: Dark
  2. Switch the OS to its light theme
  3. Reload the page

Expected: it stays dark, because the choice was explicit.
Actual: it follows the OS again.

The stored preference survives the reload (I can see it in localStorage), it just is not read before first paint.

Firefox 128 on Fedora 41, reproduced in Chromium 133.

Reply to this email directly, view it on GitHub, or unsubscribe.

' }, }, }, { - id: 'email-005', threadId: 'thread-005', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2800, receivedAt: daysAgo(4), - from: [{ name: 'Newsletter', email: 'news@techdigest.example' }], - to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Your Weekly Tech Digest', - preview: 'This week in tech: new JavaScript runtime benchmarks, WebAssembly reaches 3.0, and more.', + id: 'email-005', threadId: 'thread-005', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3300, receivedAt: daysAgo(4), + from: [{ name: 'Bram Kuipers', email: 'bram@ietf-lists.example' }], + to: [{ name: 'jmap', email: 'jmap@ietf-lists.example' }], cc: [], + subject: 'Re: [jmap] $seen on a shared mailbox: per account or per message?', + preview: 'Per message. The keyword lives on the Email object and the Email object is shared, so marking it read in one account marks it read in the other.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-009', size: 900, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-010', size: 1400, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-009', size: 800, type: 'text/plain' }], + htmlBody: [], bodyValues: { - p1: { value: 'This week in tech:\n\n1. New JavaScript runtime benchmarks show 30% improvement\n2. WebAssembly reaches version 3.0\n3. CSS container queries gain full browser support\n4. TypeScript 6.0 release candidate announced\n\nRead more at techdigest.example' }, - p2: { value: '

Your Weekly Tech Digest

  1. New JavaScript runtime benchmarks show 30% improvement
  2. WebAssembly reaches version 3.0
  3. CSS container queries gain full browser support
  4. TypeScript 6.0 release candidate announced

Read more at techdigest.example

' }, + p1: { value: 'On Tue, 24 Mar 2026 at 09:12, Astrid van der Berg wrote:\n> If two accounts have the same mailbox mapped, is $seen per account\n> or per message? We get bug reports either way.\n\nPer message. The keyword lives on the Email object, the Email object is shared, so marking it read in one account marks it read in the other. That is the reading most servers implement.\n\nIf you want per-account state you need per-account Email objects, which is what delegated mailboxes usually end up with anyway. RFC 8621 is quiet about the shared case, which is why your bug reports go both ways.\n\nWorth writing down in the interop notes before someone standardises the wrong half of it.\n\nBram\n--\njmap mailing list -- jmap@ietf-lists.example\nTo unsubscribe send an email to jmap-leave@ietf-lists.example' }, }, }, - // Newsletter with full HTML + // Newsletter with a full HTML body { - id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), - from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], + id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0), + from: [{ name: 'Sidenote', email: 'post@sidenote.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Launchpad Weekly #47 - The future of the open web', - preview: 'This week: WebAssembly Components hit 1.0, a deep dive into privacy-first analytics, and 5 tools we can\'t stop using.', + subject: 'Sidenote 47: the Component Model shipped and nobody has to care yet', + preview: 'Wasm components reached 1.0 last week. The spec is done, the toolchain is not, and that gap is the whole story.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1200, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 16000, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1900, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 9400, type: 'text/html' }], bodyValues: { - p1: { value: 'LAUNCHPAD WEEKLY #47\nThe future of the open web\n\nWebAssembly Components hit 1.0\nThe Component Model spec has reached 1.0, unlocking language-agnostic modules that run anywhere.\n\nDeep dive: Privacy-first analytics\nCookie banners are on their way out. We explore the next generation of analytics tools that respect user privacy by design.\n\n5 tools we can\'t stop using\n1. Vite 7 - lightning-fast builds\n2. Biome - unified lint + format\n3. Deno 4 - batteries included runtime\n4. TailwindCSS 4 - zero config styling\n5. Playwright - end-to-end testing\n\nYou received this because you subscribed at launchpad.example.\nUnsubscribe: https://launchpad.example/unsubscribe' }, - p2: { value: '
◆ LAUNCHPAD WEEKLY
ISSUE #47 • MARCH 2026

The future of the open web

WebAssembly Components hit 1.0, privacy-first analytics take center stage, and 5 tools we can’t stop using.

FEATURED

WebAssembly Components hit 1.0

The Component Model specification has officially reached 1.0, unlocking language-agnostic modules that compose and run anywhere — from the browser to the edge. This is a watershed moment for portable computing.

Read the deep dive →
ANALYSIS

Deep dive: Privacy-first analytics

Cookie banners are on their way out. We explore the next generation of analytics platforms that respect user privacy by design — no consent dialogs required. From server-side aggregation to differential privacy, the landscape is shifting fast.

Explore the guide →
TOOLBOX

5 tools we can’t stop using

1Vite 7
Lightning-fast builds with zero-config ESM support.
2Biome
Unified linting and formatting in a single blazing-fast tool.
3Deno 4
Batteries-included runtime with native TypeScript & npm compat.
4TailwindCSS 4
Zero-config utility-first CSS that just works.
5Playwright
Reliable end-to-end testing across every browser.

You received this because you subscribed at launchpad.example

UnsubscribeManage preferencesView in browser

' }, + p1: { value: 'SIDENOTE 47\nA weekly letter about the web, from Berlin\n\n---\n\nTHE COMPONENT MODEL SHIPPED AND NOBODY HAS TO CARE YET\n\nWasm components reached 1.0 last week. The spec is done, the toolchain is not, and that gap is the whole story.\n\nWhat you get today: a Rust crate and a JS host that can pass a record across the boundary without hand-writing glue. What you do not get: a debugger that survives the boundary, or a bundler that treats a component as a first-class input. If you are shipping a WASM module today you will keep hand-writing the glue for another year, and that is fine.\n\nThe part that matters long term is the interface types, not the packaging. Once two languages agree on what a string is, the argument moves somewhere more interesting.\n\n---\n\nCOOKIE BANNERS ARE STILL LEGAL THEATRE\n\nEvery serious analytics tool now measures without setting an identifier: aggregate at the edge, drop the raw log, and answer at the level of a page and a day instead of a person.\n\nWe ran Plausible, Umami and a self-hosted Matomo against the same fortnight of traffic. Session counts landed within 4% of each other. Where they differ is what they cannot tell you: none of them will follow a visitor across two weeks, which is the point.\n\nIf your dashboard has a funnel with six steps and a cohort retention chart, you are still identifying people. Say so in the privacy notice and stop pretending the banner covers it.\n\n---\n\nFIVE LINKS\n\n1. A write-up of a Postgres index that got slower after ANALYZE, with the plan output.\n2. The CSS working group minutes on anchor positioning. Short, and it settles the popover argument.\n3. Someone rewrote git bisect as a 90-line shell script. Useful mainly as a reading exercise.\n4. Notes from a team that moved 40 services off Kubernetes and back onto three machines.\n5. A tiny font renderer in 500 lines of C. The hinting section is worth the read on its own.\n\n---\n\nSidenote UG, Torstraße 12, 10119 Berlin\nYou get this because you signed up at sidenote.example.\nUnsubscribe: sidenote.example/unsubscribe' }, + p2: { value: `
+
+ + + + + + + + + + + + + + + +
+ + + +
SidenoteNo. 47 · 26 March
+

A weekly letter about the web, from Berlin

+
+

The Component Model shipped and nobody has to care yet

+

Wasm components reached 1.0 last week. The spec is done, the toolchain is not, and that gap is the whole story.

+

What you get today is a Rust crate and a JS host that can pass a record across the boundary without hand-written glue. What you do not get is a debugger that survives that boundary, or a bundler that treats a component as a first-class input. If you ship a WASM module this year, you will keep writing the glue by hand, and that is a reasonable place to be.

+

The durable part is the interface types, not the packaging. Once two languages agree on what a string is, the argument moves somewhere more interesting.

+ Read the full piece +
+

Cookie banners are still legal theatre

+

Every serious analytics tool now measures without setting an identifier: aggregate at the edge, drop the raw log, answer at the level of a page and a day instead of a person.

+

We pointed three of them at the same fortnight of traffic.

+ + + + + + + + + +
ToolSessionsDelta
Plausible41.208
Umami40.114−2,7%
Matomo (self-hosted)42.760+3,8%
+

Where they agree is the count. Where they differ is what they refuse to do: none of them will follow a visitor across two weeks. If your dashboard has a six-step funnel and a cohort retention chart, you are identifying people. Put that in the privacy notice and stop asking the banner to carry it.

+
+

Five links

+ + + + + + +
1A Postgres index that got slower after ANALYZE, with the plan output.
2CSSWG minutes on anchor positioning. Short, and it settles the popover argument.
3git bisect in 90 lines of shell. Useful mainly as a reading exercise.
4Forty services off Kubernetes, onto three machines, with the bill before and after.
5A font renderer in 500 lines of C. The hinting section earns the read on its own.
+
+
+ Sidenote UG, Torstraße 12, 10119 Berlin
+ You get this because you signed up at sidenote.example. + Unsubscribe · Read in the browser +
+
+
` }, }, }, // --- Additional inbox emails --- @@ -199,105 +258,213 @@ 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 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.', + cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], + subject: 'Sprint priorities for next week', + preview: 'Five items, in order. If something here is wrong, say so before the meeting rather than in it.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-030', size: 450, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-031', size: 600, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-030', size: 620, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-031', size: 820, type: 'text/html' }], bodyValues: { - p1: { value: 'Hej team,\n\nHere are the priorities for next sprint:\n\n1. Finish JMAP calendar integration\n2. Fix email threading bug (#187)\n3. Implement contact group management\n4. Performance optimization for large mailboxes\n5. Accessibility audit follow-ups\n\nPlease review before our planning meeting tomorrow at 10:00.\n\nTack,\nLars' }, - p2: { value: '

Hej team,

Here are the priorities for next sprint:

  1. Finish JMAP calendar integration
  2. Fix email threading bug (#187)
  3. Implement contact group management
  4. Performance optimization for large mailboxes
  5. Accessibility audit follow-ups

Please review before our planning meeting tomorrow at 10:00.

Tack,
Lars

' }, + p1: { value: 'Hej,\n\nFive items for next sprint, in order:\n\n1. Calendar: finish CalendarEvent/set so editing one occurrence stops dropping the other overrides\n2. Threading bug #187: messages with a rewritten Message-ID land in a thread of their own\n3. Contact groups: create, rename, membership\n4. Large mailboxes: the list view still fetches full Email objects to render a preview line\n5. Accessibility follow-ups, focus order in the composer first\n\nPlanning is tomorrow at 10:00 in room A. If something here is wrong, say so before the meeting rather than in it.\n\nLars' }, + p2: { value: '

Hej,

Five items for next sprint, in order:

  1. Calendar: finish CalendarEvent/set so editing one occurrence stops dropping the other overrides
  2. Threading bug #187: messages with a rewritten Message-ID land in a thread of their own
  3. Contact groups: create, rename, membership
  4. Large mailboxes: the list view still fetches full Email objects to render a preview line
  5. Accessibility follow-ups, focus order in the composer first

Planning is tomorrow at 10:00 in room A. If something here is wrong, say so before the meeting rather than in it.

Lars

' }, }, }, { - id: 'email-015', threadId: 'thread-014', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 5800, receivedAt: hoursAgo(5), - from: [{ name: 'Booking.com', email: 'automated@booking.example' }], + id: 'email-015', threadId: 'thread-014', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 9800, receivedAt: hoursAgo(5), + from: [{ name: 'Lago Stays', email: 'reservations@lagostays.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Prenotazione Confermata - Lake Como, Mar 28–30', - preview: 'Your reservation has been confirmed. Check-in: March 28, 2026. Check-out: March 30, 2026.', + subject: 'Booking confirmed: Villa sul Lago, Bellagio (28–30 March)', + preview: 'Reservation LS-4419-BG is confirmed. Check-in Saturday 28 March from 15:00, check-out Monday 30 March by 11:00.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-032', size: 500, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-033', size: 900, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-032', size: 700, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-033', size: 5200, type: 'text/html' }], bodyValues: { - p1: { value: 'Your reservation has been confirmed!\n\nProperty: Villa sul Lago, Bellagio, Lake Como\nCheck-in: March 28, 2026 (15:00)\nCheck-out: March 30, 2026 (11:00)\nGuests: 2\nTotal: €385,00\n\nConfirmation code: EU42GDPR\n\nHouse rules and directions are in the attached PDF.' }, - p2: { value: '

Prenotazione Confermata!

Your reservation at Villa sul Lago, Bellagio, Lake Como is confirmed.

Check-inMarch 28, 2026 (15:00)
Check-outMarch 30, 2026 (11:00)
Guests2
Total€385,00

Confirmation code: EU42GDPR

' }, + p1: { value: 'Reservation LS-4419-BG is confirmed.\n\nVilla sul Lago, Via Roma 8, 22021 Bellagio (CO), Italy\n\nCheck-in: Saturday 28 March, from 15:00\nCheck-out: Monday 30 March, by 11:00\nGuests: 2\n\n2 nights x €175,00 ... €350,00\nCleaning fee ......... €25,00\nTassa di soggiorno ... €10,00\nTotal ................ €385,00\n\nPaid in full. Free cancellation until 21 March, 23:59 CET.\n\nThe key box code is in the attached voucher. Parking is behind the building, the gate remote is on the kitchen table.\n\nMarco, your host, reads messages between 08:00 and 21:00: +39 031 950 118.' }, + p2: { value: `
+
+ + + + + + + + + + + + + + +
+ + + +
LAGO STAYSReservation LS-4419-BG
+
+

Confirmed

+

Villa sul Lago

+

Via Roma 8, 22021 Bellagio (CO), Italy

+
+ + + + + + +
+

Check-in

+

Sat 28 March

+

from 15:00

+
+

Check-out

+

Mon 30 March

+

by 11:00

+
2 guests · 2 nights · whole apartment, first floor
+
+ + + + + +
2 nights × €175,00€350,00
Cleaning fee€25,00
Tassa di soggiorno (2 × €2,50 per night)€10,00
Total, paid in full€385,00
+
+
+ The key box code is in the attached voucher. Parking is behind the building; the gate remote is on the kitchen table. +
+
+ Free cancellation until 21 March, 23:59 CET.
+ Marco, your host, reads messages between 08:00 and 21:00: +39 031 950 118. +
+ Lago Stays S.r.l., Via Statale 42, 22021 Bellagio (CO) · P.IVA IT03948210131
+ Manage this booking · Invoice +
+
` }, }, attachments: [ - { partId: 'att2', blobId: 'blob-att-002', size: 18200, name: 'conferma-prenotazione.pdf', type: 'application/pdf' }, + { partId: 'att2', blobId: 'blob-att-002', size: 18200, name: 'voucher-LS-4419-BG.pdf', type: 'application/pdf' }, ], }, { - id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3), + id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3), from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Code review request: JMAP-342 contact import', - preview: 'Salut, I just pushed the contact vCard import feature. Could you review when you get a chance?', + subject: 'JMAP-342 is up: vCard import', + preview: 'Contact import from vCard is ready for review. The part I would like you to look at is the merge UI.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-034', size: 380, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-035', size: 520, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-034', size: 640, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-035', size: 860, type: 'text/html' }], bodyValues: { - p1: { value: 'Salut,\n\nI just pushed the contact vCard import feature (JMAP-342). Could you review when you get a chance?\n\nPR: https://github.example/bulwark-webmail/pull/342\n\nKey changes:\n- New vCard parser with v3/v4 support\n- Batch import with progress indicator\n- Duplicate detection and merge UI\n- Unit tests for edge cases\n\nMerci d\'avance,\nÉlise' }, - p2: { value: '

Salut,

I just pushed the contact vCard import feature (JMAP-342). Could you review when you get a chance?

PR: bulwark-webmail/pull/342

Key changes:

  • New vCard parser with v3/v4 support
  • Batch import with progress indicator
  • Duplicate detection and merge UI
  • Unit tests for edge cases

Merci d\'avance,
Élise

' }, + p1: { value: 'Salut,\n\nJMAP-342 is up: https://github.example/bulwark-webmail/pull/342\n\nWhat is in it: a parser for vCard 3.0 and 4.0, batch import with a progress bar, and duplicate detection that matches on UID first and falls back to the email address.\n\nWhat I would like you to look at: the merge dialogue when a duplicate has conflicting fields. I went with "keep both, mark one primary" and I am not convinced that is the right call. The alternative is a field-by-field picker, which is more clicks but less surprising.\n\nThe 3.0 tests are thin. I will add the line-folding cases before it merges.\n\nÉlise' }, + p2: { value: '

Salut,

JMAP-342 is up: bulwark-webmail/pull/342

What is in it: a parser for vCard 3.0 and 4.0, batch import with a progress bar, and duplicate detection that matches on UID first and falls back to the email address.

What I would like you to look at: the merge dialogue when a duplicate has conflicting fields. I went with “keep both, mark one primary” and I am not convinced that is the right call. The alternative is a field-by-field picker, which is more clicks but less surprising.

The 3.0 tests are thin. I will add the line-folding cases before it merges.

Élise

' }, }, }, { id: 'email-017', threadId: 'thread-016', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2900, receivedAt: daysAgo(1), - from: [{ name: 'GitHub', email: 'noreply@github.com' }], + from: [{ name: 'GitHub', email: 'noreply@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[GitHub] A new sign-in from Firefox on Linux', - preview: 'We noticed a new sign-in to your account from Firefox on Linux. If this was you, no action is needed.', + subject: 'A new sign-in from Firefox on Linux', + preview: 'Your account was signed in to from a browser we have not seen before. If this was you, there is nothing to do.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-036', size: 350, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hi dev,\n\nWe noticed a new sign-in to your GitHub account.\n\nBrowser: Firefox 128\nOS: Linux (Fedora)\nLocation: Amsterdam, NL\nIP: 42.42.42.42\nTime: March 10, 2026 at 14:15 CET\n\nIf this was you, no action is needed. Don\'t Panic.\n\nIf you don\'t recognize this activity, please review your security settings.\n\nGitHub Security' }, + p1: { value: 'Your account was signed in to from a browser we have not seen before.\n\nBrowser: Firefox 128\nOperating system: Linux (Fedora 41)\nLocation: Amsterdam, Netherlands\nIP address: 145.94.12.208\nWhen: 10 March 2026 at 14:15 CET\n\nIf this was you, there is nothing to do. If it was not, change your password and review your active sessions.\n\nGitHub Security' }, }, }, { - id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), - from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], + id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 8900, receivedAt: daysAgo(1), + from: [{ name: 'Nordhost GmbH', email: 'rechnung@nordhost.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Your Hetzner invoice is available - February 2026', - preview: 'Your Hetzner Cloud invoice for February 2026 is now available. Total: €1.337,42.', + subject: 'Invoice NH-2026-0284 for February', + preview: 'Your February invoice comes to €137,35 including VAT. It will be collected by SEPA direct debit on 12 March.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-037', size: 400, type: 'text/plain' }], - htmlBody: [], + textBody: [{ partId: 'p1', blobId: 'blob-037', size: 720, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-070', size: 4600, type: 'text/html' }], bodyValues: { - p1: { value: 'Guten Tag,\n\nYour Hetzner Cloud invoice for February 2026 is now available.\n\nKundennummer: DE-4242-1337\nBilling period: Feb 1 – Feb 28, 2026\nTotal charges: €1.337,42\n\nService breakdown:\n- CX41 Dedicated: €41,20\n- Storage Box: €11,30\n- Managed Database: €47,10\n- Load Balancer: €7,43\n- Floating IPs: €8,39\n\nView your full invoice at console.hetzner.example/billing' }, + p1: { value: 'Guten Tag,\n\nInvoice NH-2026-0284 covers 1 to 28 February 2026 for customer 4181-2260.\n\nDedicated server AX41 ........ €41,20\nStorage box BX11 ............. €11,30\nManaged PostgreSQL ........... €47,10\nLoad balancer LB11 ........... €7,43\nFloating IPv4 (3) ............ €8,39\n\nNet ......................... €115,42\nVAT 19% ...................... €21,93\nTotal ....................... €137,35\n\nThe amount will be collected from IBAN DE** **** **** **** **60 01 on 12 March 2026, mandate NH-M-77213.\n\nThe PDF is attached and stays available in the console for ten years.\n\nMit freundlichen Grüßen\nNordhost GmbH' }, + p2: { value: `
+
+ + + + + + + + + + + + +
+ + + +
NORDHOSTInvoice NH-2026-0284
+
+

February 2026

+

Billing period 1–28 February · Customer 4181-2260

+
+ + + + + + + + + + + + + +
ServiceNet
Dedicated server AX41€41,20
Storage box BX11€11,30
Managed PostgreSQL€47,10
Load balancer LB11€7,43
Floating IPv4 × 3€8,39
Net€115,42
VAT 19%€21,93
Total€137,35
+
+
+ Collected by SEPA direct debit on 12 March 2026 from IBAN DE** **** **** **** **60 01, mandate NH-M-77213. No action needed. +
+
+ Open billing console +
+ Nordhost GmbH, Speicherstraße 14, 20457 Hamburg · Amtsgericht Hamburg HRB 118420
+ Geschäftsführerin: Ines Kalb · USt-IdNr. DE297441022
+ The PDF stays available in the console for ten years. +
+
` }, }, attachments: [ - { partId: 'att3', blobId: 'blob-att-003', size: 32100, name: 'Hetzner-Rechnung-Feb-2026.pdf', type: 'application/pdf' }, + { partId: 'att3', blobId: 'blob-att-003', size: 32100, name: 'Rechnung-NH-2026-0284.pdf', type: 'application/pdf' }, ], }, { id: 'email-019', threadId: 'thread-018', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3600, receivedAt: daysAgo(2), from: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], cc: [], - subject: 'Meeting notes - API design review', - preview: 'Here are the notes from today\'s API design review session. Key decisions: REST for public API, gRPC for internal services.', + subject: 'Notes from the API design review', + preview: 'Four decisions and three action items. Correct me where I have written down the wrong thing.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-038', size: 600, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-039', size: 800, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-038', size: 780, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-039', size: 1000, type: 'text/html' }], bodyValues: { - p1: { value: 'Hoi allemaal,\n\nHere are the notes from today\'s API design review:\n\nDecisions:\n1. REST for public-facing APIs (OpenAPI 3.1 spec)\n2. gRPC for internal service communication\n3. GraphQL only for the dashboard BFF\n4. Rate limiting: 100 req/min for free tier, 1000 for pro\n\nAction items:\n- Dev: Draft OpenAPI spec by Friday\n- Lars: Set up gRPC proto repository\n- Astrid: Update architecture diagrams\n\nNext review: March 18, 2026\n\nGroetjes,\nAstrid' }, - p2: { value: '

Hoi allemaal,

Here are the notes from today\'s API design review:

Decisions:

  1. REST for public-facing APIs (OpenAPI 3.1 spec)
  2. gRPC for internal service communication
  3. GraphQL only for the dashboard BFF
  4. Rate limiting: 100 req/min for free tier, 1000 for pro

Action items:

  • Dev: Draft OpenAPI spec by Friday
  • Lars: Set up gRPC proto repository
  • Astrid: Update architecture diagrams

Next review: March 18, 2026

Groetjes,
Astrid

' }, + p1: { value: 'Hoi,\n\nNotes from this morning. Correct me where I have written down the wrong thing.\n\nDecisions:\n1. REST for anything a customer touches, described in OpenAPI 3.1. Nobody wanted to hand a partner a proto file.\n2. gRPC between our own services, because the calendar sync is chatty and the payload is ours.\n3. GraphQL only in the dashboard BFF. It stays behind our own login.\n4. Rate limits: 100 requests per minute on free, 1.000 on pro, per token rather than per account.\n\nActions:\n- Dev: OpenAPI draft by Friday\n- Lars: proto repository and CI for it\n- Astrid: redraw the service diagram, the old one has two services that no longer exist\n\nNext review 18 March.\n\nGroeten,\nAstrid' }, + p2: { value: '

Hoi,

Notes from this morning. Correct me where I have written down the wrong thing.

Decisions

  1. REST for anything a customer touches, described in OpenAPI 3.1. Nobody wanted to hand a partner a proto file.
  2. gRPC between our own services, because the calendar sync is chatty and the payload is ours.
  3. GraphQL only in the dashboard BFF. It stays behind our own login.
  4. Rate limits: 100 req/min on free, 1.000 on pro, per token rather than per account.

Actions

  • Dev: OpenAPI draft by Friday
  • Lars: proto repository and CI for it
  • Astrid: redraw the service diagram, the old one has two services that no longer exist

Next review 18 March.

Groeten,
Astrid

' }, }, }, { id: 'email-020', threadId: 'thread-019', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 5200, receivedAt: daysAgo(3), from: [{ name: 'Jacques Lefèvre', email: 'jacques@lefevre-avocats.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Re: Partnership agreement - feedback', - preview: 'I reviewed the draft agreement. A few points need clarification around intellectual property clauses.', + subject: 'Re: Partnership agreement, three clauses to change', + preview: 'The draft is workable. Three clauses need to change before you sign anything.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-040', size: 700, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-040', size: 820, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Bonjour,\n\nI reviewed the draft partnership agreement. Overall it looks good, but a few points need clarification:\n\n1. Article 4.2 - IP ownership clause is ambiguous. Should specify that pre-existing IP remains with original owner.\n2. Article 7.1 - Non-compete period of 24 months may be too restrictive under EU law. Suggest 12 months.\n3. Article 9.3 - Liability cap should be tied to contract value, not a fixed amount.\n\nI\'ve marked up the document with detailed comments (attached).\n\nLet me know when you\'d like to discuss.\n\nBien cordialement,\nJacques Lefèvre\nLefèvre & Associés' }, + p1: { value: 'Bonjour,\n\nThe draft is workable. Three clauses need to change before you sign anything.\n\nArticle 4.2, intellectual property. As written, anything created during the partnership belongs to both parties, including work that predates it. Add a sentence that pre-existing IP stays with its owner and name your repositories in an annex.\n\nArticle 7.1, non-compete. Twenty-four months across the whole EU will not hold up in a French court and probably not in a German one either. Twelve months, limited to the two named market segments, survives.\n\nArticle 9.3, liability. A fixed cap of 50.000 euros is generous to them today and ruinous to you in year three. Tie it to the fees paid in the preceding twelve months.\n\nMy comments are in the attached document. I have left the rest alone, it is standard.\n\nCall me before you reply to them.\n\nBien cordialement,\nJacques Lefèvre\nLefèvre & Associés' }, }, attachments: [ - { partId: 'att4', blobId: 'blob-att-004', size: 45000, name: 'Contrat-de-Partenariat-Annoté.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + { partId: 'att4', blobId: 'blob-att-004', size: 45000, name: 'Contrat-de-Partenariat-annote.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ], }, { @@ -305,80 +472,80 @@ const emails: MockEmail[] = [ from: [{ name: 'Katrin Bauer', email: 'katrin.bauer@charite.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }, { name: 'Chiara Rossi', email: 'chiara@rossi.example' }], - subject: 'Team outing - voting on activity', - preview: 'Hey everyone! Time to vote on next month\'s team outing. Options: Biergarten, Eurovision watch party, or cooking class.', + subject: 'Team evening: pick one', + preview: 'Three options for the team evening on 24 April. Reply with a letter, voting closes Friday.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-041', size: 300, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-041', size: 340, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo zusammen!\n\nTime to vote on next month\'s team outing. Please reply with your preference:\n\nA) Biergarten evening + Bretzel buffet\nB) Eurovision watch party (with scorecards!)\nC) Cooking class (Italian cuisine - pasta fresca)\n\nVoting closes Friday. Most votes wins!\n\nKatrin' }, + p1: { value: 'Three options for the team evening on 24 April. Reply with a letter.\n\nA) Dinner at the Portuguese place near the office. Set menu, they can do vegetarian if we say so in advance.\nB) Boat tour, two hours, with something to drink on board. Cancelled if it rains.\nC) Pasta course, three hours, you eat what you make.\n\nVoting closes Friday. If we tie I will pick the cheapest.\n\nKatrin' }, }, }, { id: 'email-022', threadId: 'thread-021', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3200, receivedAt: hoursAgo(1), - from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], + from: [{ name: 'GitHub', email: 'notifications@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[vcard-parser] PR merged: Add support for FBURL property (#89)', - preview: 'Your pull request #89 has been merged into main. Thanks for contributing!', + subject: '[vcard-parser] Pull request #89 merged: FBURL property', + preview: 'Your pull request was merged into main by @maintainer.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-042', size: 280, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-042', size: 300, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Your pull request has been merged.\n\nRepository: vcard-parser\nPR #89: Add support for FBURL property\nMerged by: @maintainer\nBranch: feature/fburl → main\n\nCommits merged:\n- feat: parse FBURL property from vCard 4.0\n- test: add FBURL round-trip tests\n- docs: update README with FBURL example\n\n-\nReply to this email directly or view it on GitHub.' }, + p1: { value: 'Merged #89 into main.\n\nvcard-parser: add support for the FBURL property\nfeature/fburl -> main, merged by @maintainer\n\n feat: parse FBURL from vCard 4.0\n test: FBURL round-trip cases\n docs: FBURL example in the README\n\nThe release workflow picked it up, 2.4.0 is on the registry.\n\n-\nReply to this email directly, view it on GitHub, or unsubscribe.' }, }, }, { id: 'email-023', threadId: 'thread-022', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3800, receivedAt: hoursAgo(4), - from: [{ name: 'Support Team', email: 'support@saas-platform.example' }], + from: [{ name: 'Support', email: 'support@saas-platform.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '[Ticket #4521] Escalation: API rate limit exceeded for enterprise account', - preview: 'A customer reported hitting rate limits despite being on the enterprise plan. This has been escalated to engineering.', + subject: 'Ticket #4521 escalated: enterprise account hitting the rate limit', + preview: 'EuroTech GmbH is on the enterprise plan and still getting 429s. Their bursts go over the limit, their average is well under it.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-043', size: 500, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-043', size: 620, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo Dev,\n\nTicket #4521 has been escalated to engineering.\n\nCustomer: EuroTech GmbH (Enterprise plan)\nIssue: API rate limit exceeded\nImpact: Production integration failing intermittently\n\nDetails:\n- Customer is hitting the 1000 req/min limit\n- Their usage pattern shows bursts of 2000+ req/min during peak hours\n- They\'re requesting a temporary increase to 5000 req/min\n\nCan you review the rate limiting config and advise?\n\nPriority: High\nSLA: 4 hours\n\nDanke,\nSupport Team' }, + p1: { value: 'Ticket #4521 is with engineering now.\n\nCustomer: EuroTech GmbH, enterprise plan\nSymptom: 429 responses during their nightly sync, roughly 03:00 to 03:40 CET\n\nWhat the logs show: their average is 340 requests per minute, well under the 1.000 limit. The bursts hit 2.100 for about ninety seconds while the sync opens every mailbox at once.\n\nThey have asked for 5.000 per minute. I would rather we let them burst than raise the ceiling for everyone, but that is your call.\n\nSLA on this one is four hours and it started at 11:20.\n\nDanke,\nMirjam' }, }, }, { id: 'email-024', threadId: 'thread-023', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 7200, receivedAt: daysAgo(5), - from: [{ name: 'DEV Community', email: 'digest@dev.to.example' }], + from: [{ name: 'DEV Community', email: 'digest@dev-community.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'DEV Digest - Top posts this week', - preview: 'This week\'s top posts: "Why I switched from React to Solid", "Building a CLI tool in Rust", and more.', + subject: 'Most read this week', + preview: 'Why I moved off React and what it cost, a CLI in Rust without clap, and the state of CSS in 2026.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-044', size: 800, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-045', size: 1200, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-044', size: 780, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-045', size: 1300, type: 'text/html' }], bodyValues: { - p1: { value: 'DEV Digest - Top posts this week\n\n1. "Why I switched from React to Solid" by @webdev - 342 reactions\n2. "Building a CLI tool in Rust from scratch" by @rustacean - 289 reactions\n3. "The state of CSS in 2026" by @cssmaster - 256 reactions\n4. "Microservices are dead, long live modular monoliths" by @architect - 234 reactions\n5. "A beginner\'s guide to WebAssembly Components" by @wasmdev - 198 reactions\n\nHappy coding!\nThe DEV Team' }, - p2: { value: '

DEV Digest

Top posts this week:

  1. "Why I switched from React to Solid" - 342 reactions
  2. "Building a CLI tool in Rust from scratch" - 289 reactions
  3. "The state of CSS in 2026" - 256 reactions
  4. "Microservices are dead, long live modular monoliths" - 234 reactions
  5. "A beginner\'s guide to WebAssembly Components" - 198 reactions

Happy coding!
The DEV Team

' }, + p1: { value: 'Most read this week\n\n1. Why I moved off React and what it cost, by @webdev (342 reactions)\n2. Writing a CLI in Rust without clap, by @rustacean (289)\n3. The state of CSS in 2026, by @cssmaster (256)\n4. Microservices are dead, long live the modular monolith, by @architect (234)\n5. WebAssembly components for people who write JavaScript, by @wasmdev (198)\n\nManage what lands in your inbox: dev-community.example/settings' }, + p2: { value: '' }, }, }, { - id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6), - from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }], + id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6), + from: [{ name: 'Mollie Developers', email: 'developers@mollie.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Action required: API v2023-10 deprecation on April 15, 2026', - preview: 'Stripe API version 2023-10 will be deprecated on April 15, 2026. Please upgrade to v2025-01 before then.', + subject: 'API version 2023-10 stops working on 15 April', + preview: 'Two of your API keys still send version 2023-10. After 15 April those calls return 410.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-046', size: 550, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-046', size: 640, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Important: API Deprecation Notice\n\nStripe API version 2023-10 will be deprecated on April 15, 2026.\n\nWhat you need to do:\n1. Review the migration guide: https://stripe.example/docs/upgrades\n2. Update your API version to 2025-01\n3. Test your integration in test mode\n4. Deploy changes before April 15\n\nBreaking changes in v2025-01:\n- Payment Intent confirmation flow updated\n- Webhook event structure changes\n- Deprecated parameters removed\n\nQuestions? Contact developer-support@stripe.example\n\nStripe Developer Relations' }, + p1: { value: 'Two of your API keys still send version 2023-10:\n\n live_k4m...9tz last used 2 hours ago\n test_p1q...44b last used yesterday\n\nAfter 15 April 2026 those calls return 410 Gone.\n\nWhat changes in 2025-01, for the endpoints you use:\n\n- Payment confirmation is a single call. The separate confirm step is gone.\n- Webhook payloads wrap the object in "data" and add "eventId".\n- The deprecated "metadata_json" parameter has been removed. Use "metadata".\n\nMigration guide: mollie.example/docs/upgrades/2025-01\nTest mode accepts the new version today, so you can switch one key and watch it.\n\nMollie Developer Relations' }, }, }, { id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1), - from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], + from: [{ name: 'Sophie Müller', 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', - preview: 'Looks good! I\'d also suggest we add the email signature editor to the list. I can take that one.', + subject: 'Re: Sprint priorities for next week', + preview: 'The order looks right. Can we add the signature editor? It is half done and it keeps coming back in support tickets.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-047', size: 200, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-047', size: 260, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Sieht gut aus! I\'d also suggest we add the email signature editor to the list. I can take that one.\n\nAlso, can we move the planning meeting to 10:30? I have a conflict at 10.\n\nSophie' }, + p1: { value: 'The order looks right.\n\nCan we add the signature editor? It is half done and it keeps coming back in support tickets. I can take it, it is two days at most.\n\nAlso: 10:30 instead of 10:00 for planning? I have a call that runs to the hour.\n\nSophie' }, }, }, { @@ -386,13 +553,13 @@ const emails: MockEmail[] = [ from: [{ name: 'Liam Ó Donaill', email: 'liam.odonaill@finanz.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Nils Andersson', email: 'nils@digitaal.example' }], - subject: 'Q1 Budget Review - Meeting this Thursday', - preview: 'Hi, let\'s meet Thursday at 14:00 to review the Q1 engineering budget. Please bring your team\'s actuals.', + subject: 'Q1 budget review, Thursday 14:00', + preview: 'Bring your actual spend. The forecast column in the sheet is mine, the actuals column is yours and it is empty.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-062', size: 350, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-062', size: 480, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Dia duit,\n\nLet\'s meet Thursday at 14:00 to review the Q1 engineering budget.\n\nAgenda:\n1. Actuals vs. forecast (see attached)\n2. Cloud infrastructure cost optimization\n3. Headcount planning for Q2\n4. Software license renewals\n\nPlease bring your team\'s actual spend numbers.\n\nMeeting room: Konferenzsaal B / Zoom link in calendar invite\n\nLiam' }, + p1: { value: 'Thursday at 14:00, room B, one hour. Zoom link is in the calendar invitation.\n\nAgenda:\n1. Actuals against forecast\n2. Cloud spend, which is 18% over and I would like to know why before I ask upstairs\n3. Headcount for Q2\n4. Licence renewals, three of them run out in May\n\nBring your actual spend. The forecast column in the attached sheet is mine, the actuals column is yours and it is empty.\n\nLiam' }, }, attachments: [ { partId: 'att9', blobId: 'blob-att-009', size: 54000, name: 'Q1-Budget-Vorlage.xlsx', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, @@ -402,13 +569,13 @@ const emails: MockEmail[] = [ id: 'email-041', threadId: 'thread-036', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2600, receivedAt: daysAgo(7), from: [{ name: 'María García', email: 'maria@garcia-design.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Updated brand guidelines and component library', - preview: 'Hola! The new brand guidelines are finalized. I\'ve also updated the Figma component library.', + subject: 'Brand guidelines v2 and what it means for the app', + preview: 'The guidelines are final. Two changes touch the app: the primary colour and the heading font.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-063', size: 350, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-063', size: 520, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: '¡Hola!\n\nThe new brand guidelines are finalized. Key updates:\n\n- Primary color shifted to #7c3aed (from #6366f1)\n- New typography scale (Inter for body, Cal Sans for headings)\n- Updated icon set (Lucide → custom icon font)\n- Dark mode color tokens added\n\nI\'ve also updated the Figma component library. Link: figma.example/bulwark-webmail-v2\n\nBrand guidelines PDF attached.\n\nMaría' }, + p1: { value: 'The guidelines are final. Two changes touch the app:\n\nPrimary colour moves from #6366f1 to #7c3aed. It passes AA on white at 14px, which the old one did not, so the small print in the composer stops being a problem.\n\nHeadings move to Cal Sans, body stays Inter. Only headings, so the change is two font faces, not twelve.\n\nThe icons and the dark mode tokens are in the Figma library, same file, page "App v2". Nothing there is new, I only named the tokens properly so they can be read by a script.\n\nPDF attached. Ask before you improvise a shade, I will say yes to most things.\n\nUn saludo,\nMaría' }, }, attachments: [ { partId: 'att10', blobId: 'blob-att-010', size: 3200000, name: 'Markenrichtlinien-v2.pdf', type: 'application/pdf' }, @@ -419,26 +586,71 @@ const emails: MockEmail[] = [ from: [{ name: 'Nils Andersson', email: 'nils@digitaal.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Fika next week?', - preview: 'Hej! Haven\'t caught up in a while. Free for a fika next week? Tuesday or Wednesday work best for me.', + preview: 'Tuesday or Wednesday work for me. There is a place on the Prinsengracht that has finally learned to make a kanelbulle.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-064', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-064', size: 180, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej!\n\nHaven\'t caught up in a while. Free for a fika next week? Tuesday or Wednesday work best for me.\n\nThere\'s a great new café on the Prinsengracht I\'ve been wanting to try - they do a wonderful kanelbulle.\n\nNils' }, + p1: { value: 'Tuesday or Wednesday work for me, after 15:00 either day.\n\nThere is a place on the Prinsengracht that has finally learned to make a kanelbulle. Low bar, met.\n\nNils' }, }, }, { - id: 'email-039', threadId: 'thread-034', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(0.25), - from: [{ name: 'CI/CD Pipeline', email: 'ci@github.example' }], + id: 'email-039', threadId: 'thread-034', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 6400, receivedAt: hoursAgo(0.25), + from: [{ name: 'CI', email: 'ci@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '❌ Build failed: main - bulwark-webmail #1337', - preview: 'Build #1337 on branch main failed. 2 test(s) failed in email-sanitization.test.ts.', + subject: '❌ bulwark-webmail #482 failed on main', + preview: 'Two tests failed in email-sanitization.test.ts. Both of them are about style attributes.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-060', size: 450, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-061', size: 600, type: 'text/html' }], + textBody: [{ partId: 'p1', blobId: 'blob-060', size: 620, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-061', size: 3900, type: 'text/html' }], bodyValues: { - p1: { value: '❌ Build #1337 FAILED\n\nRepository: bulwark-webmail\nBranch: main\nCommit: a3f9c21 "fix: sanitize CSS in email body"\nTriggered by: @elise-moreau\n\nFailed tests:\n ✗ email-sanitization.test.ts > should strip javascript: URLs\n ✗ email-sanitization.test.ts > should handle nested style tags\n\nPassed: 247 | Failed: 2 | Skipped: 0\nDuration: 42.0s\n\nView full logs: https://github.example/bulwark-webmail/actions/runs/1337' }, - p2: { value: '

❌ Build #1337 FAILED

Repositorybulwark-webmail
Branchmain
Commita3f9c21 "fix: sanitize CSS in email body"

Failed tests:

  • email-sanitization.test.ts > should strip javascript: URLs
  • email-sanitization.test.ts > should handle nested style tags

Passed: 247 | Failed: 2 | Skipped: 0

' }, + p1: { value: 'Build #482 failed on main.\n\nCommit a3f9c21 fix: sanitize CSS in email bodies\nAuthor Élise Moreau\nRan 42s on ubuntu-24.04, node 22\n\n247 passed, 2 failed, 0 skipped\n\nFAIL lib/__tests__/email-sanitization.test.ts\n x strips javascript: URLs from style attributes\n expected:
\n received:
\n x drops @import inside a nested style tag\n expected 0 matches for /@import/, got 1\n\nLogs: https://ci.fjord-systems.example/runs/482' }, + p2: { value: `
+
+ + + + + + + + + + +
+ + + +
Build #482 failedmain · 42s
+
+ + + + + +
Repositorybulwark-webmail
Commita3f9c21 fix: sanitize CSS in email bodies
AuthorÉlise Moreau
Runnerubuntu-24.04, node 22
+
+ + + + + + +
247 passed2 failed0 skipped
+
+
+ FAIL lib/__tests__/email-sanitization.test.ts
+   × strips javascript: URLs from style attributes
+     expected <div style="">
+     received <div style="background:url(javascript:alert(1))">
+   × drops @import inside a nested style tag
+     expected 0 matches for /@import/, got 1 +
+
+ View the run + Re-run failed tests +
+
` }, }, }, // ===================================================================== @@ -448,92 +660,92 @@ const emails: MockEmail[] = [ id: 'email-006', threadId: 'thread-003', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1800, receivedAt: daysAgo(2), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], cc: [], - subject: 'Re: Pranzo domani?', - preview: 'Perfetto! Let\'s meet at noon.', + subject: 'Re: Lunch tomorrow?', + preview: '12:30 works. I will be the one already sitting down.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-011', size: 80, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-011', size: 110, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Perfetto! Let\'s meet at noon by the Herengracht.\n\n- Dev User' }, + p1: { value: '12:30 works. I will be the one already sitting down.\n\nIf the risotto is bad we are never speaking of this again.' }, }, }, { id: 'email-007', threadId: 'thread-006', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2200, receivedAt: daysAgo(3), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], cc: [], - subject: 'Re: Project Update - Q1 Review', - preview: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.', + subject: 'Re: Q1 numbers, and the part I want to talk about', + preview: 'Twenty minutes is fine. Can you send the funnel numbers per step beforehand?', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-012', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-012', size: 210, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.\n\nCheers,\nDev User' }, + p1: { value: 'Twenty minutes is fine, put it at the front of the call.\n\nCan you send the funnel numbers per step beforehand? If the drop is at payment details I would like to know whether it is the form or the card.\n\nDev' }, }, }, { 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 Example', email: 'sophie@eurotech.example' }], cc: [], - subject: 'Design review feedback', - preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.', + to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [], + subject: 'Mockup feedback', + preview: 'Three notes on the new screens, none of them blocking.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-013', size: 300, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-013', size: 380, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo Sophie,\n\nI reviewed the new mockups and have a few suggestions:\n\n1. The sidebar could use more contrast\n2. Consider adding breadcrumbs to the settings page\n3. The compose button placement looks good\n\nOverall great work!\n\nDev User' }, + p1: { value: 'Three notes, none of them blocking.\n\nThe sidebar labels sit at about 3:1 against the background. On my laptop outdoors they disappear.\n\nSettings needs a way back out. Breadcrumbs or a title with the section name, either is fine.\n\nThe compose button where it is now is right. I was wrong about that in the last round.\n\nDev' }, }, }, { 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 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.', + cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], + subject: 'Re: Sprint priorities for next week', + preview: '10:30 works. Signature editor goes in as item six, below the accessibility follow-ups.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-048', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-048', size: 220, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.\n\nLars - let\'s also add a stretch goal for the email template system if we finish early.\n\n- Dev User' }, + p1: { value: '10:30 works, I moved the invitation.\n\nSignature editor goes in as item six, below the accessibility follow-ups. Sophie, if it really is two days, it lands. If it turns into four, it comes back out.\n\nDev' }, }, }, { id: 'email-028', threadId: 'thread-015', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2100, receivedAt: daysAgo(1), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [], - subject: 'Re: Code review request: JMAP-342 contact import', - preview: 'Nice work on the vCard parser! Left a few comments on the PR. Main concern is memory usage for large imports.', + subject: 'Re: JMAP-342 is up: vCard import', + preview: 'Comments are on the PR. Keep the merge dialogue as it is, but the import needs to stream.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-049', size: 280, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-049', size: 340, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Nice work on the vCard parser! Left a few comments on the PR.\n\nMain concern: memory usage for large imports (1000+ contacts). Consider using a streaming parser instead of loading the entire file.\n\nAlso, the duplicate detection logic looks solid. Approved with minor changes.\n\n- Dev User' }, + p1: { value: 'Comments are on the PR.\n\nKeep the merge dialogue as it is. "Keep both, mark one primary" is recoverable, a wrong field pick is not, and people will click through the picker without reading it.\n\nThe import reads the whole file into memory first. At 1.200 contacts my tab used 380 MB. Stream it and I will approve.\n\nDev' }, }, }, { id: 'email-029', threadId: 'thread-018', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1600, receivedAt: daysAgo(2), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], cc: [], - subject: 'Re: Meeting notes - API design review', - preview: 'Thanks for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.', + subject: 'Re: Notes from the API design review', + preview: 'One correction: the rate limit is per token, not per key. Draft lands Friday.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-050', size: 120, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-050', size: 190, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Bedankt for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.\n\n- Dev User' }, + p1: { value: 'One correction: we said per token, and a customer can hold several. That matters for the enterprise ticket that is open right now.\n\nOpenAPI draft lands Friday.\n\nDev' }, }, }, { id: 'email-030', threadId: 'thread-025', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 4800, receivedAt: daysAgo(4), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Team', email: 'team@fjord-systems.example' }], cc: [], - subject: 'Proposal: Migrate from REST to JMAP for mail backend', - preview: 'I\'ve been researching JMAP as a replacement for our current REST-based mail backend. Here\'s the proposal.', + subject: 'Proposal: move the mail backend to JMAP', + preview: 'Our REST layer is a worse version of a protocol that already exists. The proposal is to stop maintaining it.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-051', size: 900, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-051', size: 1100, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej team,\n\nI\'ve been researching JMAP (RFC 8620/8621) as a replacement for our current REST-based mail backend. Here\'s a summary:\n\nWhy JMAP?\n- Eliminates N+1 query problems with batch requests\n- Built-in push notifications via EventSource\n- Efficient delta sync reduces bandwidth by 60-80%\n- Standardized protocol with growing ecosystem\n\nProposed timeline:\n- Phase 1 (Mar): Proof of concept with mock server\n- Phase 2 (Apr): Core email operations\n- Phase 3 (May): Calendar & contacts integration\n- Phase 4 (Jun): Migration from legacy API\n\nFull proposal document attached.\n\n- Dev User' }, + p1: { value: 'Our REST layer is a worse version of a protocol that already exists. The proposal is to stop maintaining it and speak JMAP (RFC 8620 and 8621) directly.\n\nWhat we get:\n\n- One request instead of the current fan-out. Opening a 40-message thread costs us 41 calls today.\n- Push over EventSource, so we can delete the polling worker and the Redis key it uses to deduplicate.\n- Delta sync. In the prototype, a warm sync of a 12.000-message mailbox moved 240 KB instead of 3,1 MB.\n\nWhat it costs:\n\n- Two people for roughly ten weeks.\n- A migration path for the three integrations that read our REST endpoints. Two are internal, one is a customer and needs notice.\n\nRough plan: prototype in March against a mock server, core mail in April, calendar and contacts in May, cut over in June with the old endpoints kept read-only until September.\n\nFull write-up attached, including the numbers behind the sync figure.\n\nDev' }, }, attachments: [ { partId: 'att5', blobId: 'blob-att-005', size: 67000, name: 'JMAP-Migration-Proposal.pdf', type: 'application/pdf' }, @@ -546,39 +758,39 @@ const emails: MockEmail[] = [ id: 'email-009', threadId: 'thread-008', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1200, receivedAt: daysAgo(0), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Team', email: 'team@fjord-systems.example' }], cc: [], - subject: 'Meeting notes (draft)', - preview: 'Notes from today\'s standup meeting...', + subject: 'Standup notes', + preview: 'Blocked on the CalendarEvent/set override question...', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-014', size: 200, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-014', size: 220, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Notes from today\'s standup meeting:\n\n- TODO: fill in details\n- Action items: ...' }, + p1: { value: 'Yesterday: threading bug, no cause yet\nToday: CalendarEvent/set overrides\nBlocked on: whether we keep the old override when the recurrence rule changes\n\nTODO: ask Lars before sending this' }, }, }, { id: 'email-031', threadId: 'thread-026', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 2400, receivedAt: hoursAgo(6), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [], cc: [], - subject: 'Blog post: Building a JMAP client from scratch (draft)', - preview: 'Introduction: JMAP is a modern, efficient protocol for email, calendar, and contacts...', + subject: 'Blog post: a JMAP client in 200 lines', + preview: 'IMAP makes you ask twelve times. JMAP lets you ask once. That is most of the difference...', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-052', size: 500, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-052', size: 560, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Building a JMAP Client from Scratch\n\nIntroduction:\nJMAP is a modern, efficient protocol for email, calendar, and contacts. Unlike IMAP, it uses JSON over HTTP, making it much easier to work with in web applications.\n\nIn this post, we\'ll build a minimal JMAP client in TypeScript that can:\n- Authenticate and discover capabilities\n- List mailboxes and messages\n- Send emails\n\n[TODO: Add code examples]\n[TODO: Add section on error handling]\n[TODO: Conclusion]' }, + p1: { value: 'A JMAP client in 200 lines\n\nIMAP makes you ask twelve times. JMAP lets you ask once, and that is most of the difference. The rest is JSON over HTTP, which means the whole thing fits in a file you can read on a train.\n\nWe will get a session, list mailboxes, page through an inbox and send one message.\n\n[TODO: session discovery, mention the .well-known redirect trap]\n[TODO: back-references, this is the part people miss]\n[TODO: error handling, the "notCreated" shape is unusual]\n[TODO: closing, do not turn it into a manifesto]' }, }, }, { id: 'email-032', threadId: 'thread-027', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1800, receivedAt: daysAgo(1), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'CFP Committee', email: 'cfp@fosdem.example' }], cc: [], - subject: 'Talk proposal: Modern email clients with JMAP', - preview: 'Title: Modern Email Clients with JMAP - From Protocol to Production...', + subject: 'Talk proposal: webmail on JMAP', + preview: 'Title: What a webmail client looks like when the protocol is on your side...', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-053', size: 400, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-053', size: 460, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Title: Modern Email Clients with JMAP - From Protocol to Production\n\nAbstract:\nThis talk explores building a full-featured webmail client using the JMAP protocol. We\'ll cover session negotiation, efficient data sync, real-time push notifications, and lessons learned.\n\nConference: FOSDEM 2027\nFormat: 30-minute talk\nLevel: Intermediate\n\n[TODO: Add speaker bio]\n[TODO: Complete outline]' }, + p1: { value: 'Title: What a webmail client looks like when the protocol is on your side\n\nAbstract:\nWe built a webmail client on JMAP instead of an IMAP bridge. This talk covers what got easier (sync, push, search), what got harder (nothing else speaks it yet), and the three places where the spec left us to decide for ourselves.\n\nTrack: Modern Email\nFormat: 30 minutes\nLevel: intermediate\n\n[TODO: speaker bio, keep it to three lines]\n[TODO: outline, five bullets is enough]' }, }, }, // ===================================================================== @@ -586,41 +798,41 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-010', threadId: 'thread-009', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 4500, receivedAt: daysAgo(1), - from: [{ name: 'Totally Real Prince', email: 'prince@scam.example' }], + from: [{ name: 'EuroMillions Claims Dept', email: 'claims@euro-lotto-payout.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'You have won €1.000.000!!!', - preview: 'Congratulations! You have been selected as the winner of our international lottery.', + subject: 'FINAL NOTICE: your prize of €1.000.000 is waiting', + preview: 'Your email address was drawn in our international promotional draw. To release the funds we require your bank details.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-015', size: 500, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-015', size: 520, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Congratulations!\n\nYou have been selected as the winner of our international lottery. To claim your prize, please send your IBAN details to...\n\nCeci n\'est pas un spam.' }, + p1: { value: 'ATTENTION BENEFICIARY,\n\nYour email address was drawn in our international promotional draw held in Madrid. Prize: ONE MILLION EURO (€1.000.000,00).\n\nTo release the funds our processing office requires:\n1. Full name and address\n2. Copy of passport\n3. IBAN and BIC\n4. Processing fee of €450 (refundable)\n\nReply within 72 hours or the prize passes to the next beneficiary.\n\nMrs. Elizabeth Okon\nClaims Officer' }, }, }, { id: 'email-033', threadId: 'thread-028', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 2200, receivedAt: hoursAgo(8), from: [{ name: 'HTCPCP Service', email: 'noreply@teapot.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '418 I\'m a Teapot - Your coffee request was denied', - preview: 'Per RFC 2324, this server is a teapot and cannot brew coffee. Please try a coffee pot instead.', + subject: '418 I am a teapot', + preview: 'Per RFC 2324 this server is a teapot. Your BREW request has been declined.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-054', size: 250, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'HTTP/1.1 418 I\'m a Teapot\n\nPer RFC 2324 (Hyper Text Coffee Pot Control Protocol), this server is, in fact, a teapot. It is short and stout. It cannot brew coffee.\n\nPlease redirect your BREW request to a proper coffee pot.\n\nContent-Type: message/coffeepot\n\nThe teapot abides.' }, + p1: { value: 'HTTP/1.1 418 I am a teapot\nContent-Type: message/coffeepot\n\nPer RFC 2324, this server is a teapot. It is short and stout. Your BREW request has been declined.\n\nPlease direct it at a device that can actually make coffee.' }, }, }, { id: 'email-034', threadId: 'thread-029', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 1900, receivedAt: daysAgo(2), from: [{ name: 'CryptoTrader Pro', email: 'earn@crypto-gains.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Turn €100 into €10.000 in just 7 days! 🚀', - preview: 'Our AI trading bot has a 99.9% success rate. Start earning today!', + subject: 'Turn €100 into €10.000 in 7 days 🚀', + preview: 'Our trading bot closed 99,9% of positions in profit last month. Places are limited.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-055', size: 300, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'LIMITED TIME OFFER!\n\nOur revolutionary AI trading bot:\n- 99.9% success rate\n- Guaranteed returns\n- No experience needed\n\nSign up now at crypto-gains.example!' }, + p1: { value: 'LIMITED PLACES!!!\n\nOur trading bot closed 99,9% of positions in profit last month.\n\n- No experience needed\n- Withdraw any time*\n- Start with only €100\n\nSign up today at crypto-gains.example\n\n*after the 90 day qualifying period' }, }, }, // ===================================================================== @@ -628,41 +840,41 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-011', threadId: 'thread-010', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 3800, receivedAt: daysAgo(14), - from: [{ name: 'HR Department', email: 'hr@fjord-systems.example' }], + from: [{ name: 'People & Culture', email: 'hr@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Updated Holiday Policy - EU Directive Compliance', - preview: 'Please review the updated paid leave policy effective next month, now with 30 days minimum.', + subject: 'Leave policy from 1 April', + preview: 'Annual leave goes to 30 days for everyone, and approval moves out of email and into the HR system.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-016', size: 600, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-016', size: 620, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej team,\n\nPlease review the updated paid leave policy effective next month. Key changes include:\n\n- Minimum annual leave: 30 days (EU directive compliance)\n- New flexible Friday policy - Freitags um 14:00 Schluss\n- Simplified approval workflow\n- Fika breaks are now officially protected time\n\nFull details in the employee handbook.\n\nBästa hälsningar,\nHR Department' }, + p1: { value: 'The leave policy changes on 1 April.\n\nAnnual leave goes to 30 days for everyone, including the two contracts that were on 25. Days already booked keep their approval.\n\nCarry-over is capped at 10 days and expires on 31 March of the following year. This is new, and it is the part people will read too late.\n\nApproval moves out of email and into the HR system. Your manager gets a notification, you get a calendar entry when it is approved.\n\nThe handbook has the full text. Questions go to hr@, not to your manager, we would rather answer once.\n\nPeople & Culture' }, }, }, { id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30), - from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], + from: [{ name: 'Sophie Müller', 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!', + subject: 'Your talk was accepted', + preview: 'Day one, 14:00, main hall. Thirty minutes plus ten for questions.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-017', size: 350, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-017', size: 380, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Toll!\n\nYour talk proposal "Building Modern Webmail with JMAP" for the JMAP Conf in Amsterdam has been accepted!\n\nThe conference is scheduled for next month at the RAI. More details to follow.\n\nHerzlichen Glückwunsch!\nSophie' }, + p1: { value: 'The committee took "Building modern webmail with JMAP" for the Amsterdam conference.\n\nDay one, 14:00, main hall. Thirty minutes plus ten for questions. They want slides by the Friday before, in PDF, because the last speaker who brought Keynote cost them twenty minutes.\n\nTravel is booked, hotel is not. Tell me if you want the one next to the RAI or the quiet one twenty minutes away.\n\nSophie' }, }, }, { id: 'email-035', threadId: 'thread-030', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 4200, receivedAt: daysAgo(60), - from: [{ name: 'IT Abteilung', email: 'it@fjord-systems.example' }], + from: [{ name: 'IT', email: 'it@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Välkommen! Your development environment setup guide', - preview: 'Welcome to the team! Here\'s everything you need to set up your development environment.', + subject: 'Your development setup', + preview: 'Everything you need for the first day, in the order that works.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-056', size: 800, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-056', size: 820, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Välkommen till laget!\n\nHere\'s your development environment setup guide:\n\n1. Clone the monorepo: git clone git@gitlab.example:fjord/monorepo.git\n2. Install dependencies: npm install\n3. Set up local database: docker-compose up -d\n4. Configure environment variables (see .env.example)\n5. Run the test suite: npm test\n\nAccess credentials:\n- Jira: your-email (SSO)\n- GitLab: your-email (SSO)\n- Hetzner Console: IAM user created, check Bitwarden\n\nQuestions? Reach out on #dev-onboarding in Mattermost.\n\nBästa hälsningar,\nIT Abteilung' }, + p1: { value: 'Welcome. Everything you need for the first day, in the order that works:\n\n1. git clone git@gitlab.example:fjord/monorepo.git\n2. npm install (node 22, the repo pins it)\n3. docker compose up -d for Postgres and the mail server\n4. cp .env.example .env, then ask in #dev-onboarding for the two secrets that are not in it\n5. npm test, which should be green before you change anything\n\nAccounts: GitLab and Jira are behind SSO, so your login already works. The hosting console needs an IAM user, it is in your Bitwarden collection.\n\nThe setup guide is attached. It is a year old and mostly right. Where it is wrong, edit it, that is what it is for.\n\nIT' }, }, attachments: [ { partId: 'att6', blobId: 'blob-att-006', size: 125000, name: 'Entwicklung-Setup-Guide.pdf', type: 'application/pdf' }, @@ -672,13 +884,13 @@ const emails: MockEmail[] = [ id: 'email-036', threadId: 'thread-031', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 3100, receivedAt: daysAgo(45), from: [{ name: 'ELSTER Online', email: 'noreply@elster.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Ihre Steuererklärung 2025 - Dokumente bereit', - preview: 'Ihre Lohnsteuerbescheinigung und Steuerbescheid sind zum Download bereit.', + subject: 'Ihre Dokumente für die Steuererklärung 2025 stehen bereit', + preview: 'Lohnsteuerbescheinigung und Bescheinigung über gezahlte Kirchensteuer liegen im Postfach bereit.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-057', size: 300, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-057', size: 380, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Sehr geehrte/r Steuerpflichtige/r,\n\nIhre Steuerdokumente für 2025 sind jetzt verfügbar:\n\n- Lohnsteuerbescheinigung\n- Steuerbescheid\n- Bescheinigung über Kirchensteuer\n\nAbgabefrist: 31. Juli 2026\n\nMelden Sie sich bei elster.example an, um Ihre Erklärung einzureichen.\n\nMit freundlichen Grüßen,\nFinanzamt' }, + p1: { value: 'Sehr geehrte Steuerpflichtige, sehr geehrter Steuerpflichtiger,\n\nfolgende Dokumente stehen in Ihrem Postfach bereit:\n\n- Lohnsteuerbescheinigung 2025\n- Bescheinigung über gezahlte Kirchensteuer\n- Vorausgefüllte Steuererklärung (Entwurf)\n\nAbgabefrist ohne steuerliche Beratung: 31. Juli 2026.\n\nBitte melden Sie sich mit Ihrem Zertifikat unter elster.example an. Wir fordern Sie niemals per E-Mail zur Eingabe Ihrer Zugangsdaten auf.\n\nMit freundlichen Grüßen\nIhr Finanzamt' }, }, attachments: [ { partId: 'att7', blobId: 'blob-att-007', size: 89000, name: 'Steuerdokumente-2025.pdf', type: 'application/pdf' }, @@ -689,16 +901,16 @@ const emails: MockEmail[] = [ from: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], - subject: 'Team building photos from last Friday', - preview: 'Che bella serata! Sharing the photos from our team building event at the Biergarten.', + subject: 'Photos from Friday', + preview: 'Everything from Friday evening, unsorted. Tell me if you want one taken down.', hasAttachment: true, - textBody: [{ partId: 'p1', blobId: 'blob-058', size: 150, type: 'text/plain' }], + textBody: [{ partId: 'p1', blobId: 'blob-058', size: 200, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Che bella serata! 🎉\n\nSharing the photos from our team building event at the Biergarten am Prinsengracht. The Bretzel eating contest was legendary!\n\nPhotos attached. Feel free to share.\n\nChiara' }, + p1: { value: 'Everything from Friday evening, unsorted, 84 of them.\n\nThere are four where Pierre is mid-sentence and looks furious. I kept them.\n\nTell me if you want one taken down before I put the album anywhere else.\n\nChiara' }, }, attachments: [ - { partId: 'att8', blobId: 'blob-att-008', size: 2400000, name: 'teambuilding-fotos.zip', type: 'application/zip' }, + { partId: 'att8', blobId: 'blob-att-008', size: 2400000, name: 'fotos-vrijdag.zip', type: 'application/zip' }, ], }, // ===================================================================== @@ -706,15 +918,16 @@ const emails: MockEmail[] = [ // ===================================================================== { id: 'email-038', threadId: 'thread-033', mailboxIds: { 'mb-trash': true }, keywords: { $seen: true }, size: 3500, receivedAt: daysAgo(1), - from: [{ name: 'SaaS Product', email: 'marketing@saas-product.example' }], + from: [{ name: 'Kanbanist', email: 'hello@kanbanist.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '🎉 50% off annual plans - limited time!', - preview: 'Upgrade to our annual plan and save 50%. Offer expires this Sunday.', + subject: 'Your trial ends Sunday', + preview: 'Annual plans are 30% off until Sunday. After that your workspace goes read-only.', hasAttachment: false, - textBody: [{ partId: 'p1', blobId: 'blob-059', size: 400, type: 'text/plain' }], - htmlBody: [], + textBody: [{ partId: 'p1', blobId: 'blob-059', size: 420, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-065', size: 1200, type: 'text/html' }], bodyValues: { - p1: { value: 'Spring sale is here!\n\nUpgrade to our annual plan and save 50%.\n\nWhat you get:\n- Unlimited users\n- Priority support\n- Advanced analytics\n- Custom integrations\n\nOffer expires Sunday, March 15, 2026.\n\nUpgrade now at saas-product.example/pricing' }, + p1: { value: 'Your trial ends on Sunday 15 March.\n\nAnnual plans are 30% off until then: €84 per user per year instead of €120.\n\nAfter Sunday your workspace stays readable for 30 days, then it is deleted. Exports are in Settings > Data.\n\nkanbanist.example/billing' }, + p2: { value: '

Your trial ends on Sunday 15 March.

Annual plans are 30% off until then: €84 per user per year instead of €120.

After Sunday your workspace stays readable for 30 days, then it is deleted. Exports live in Settings › Data.

Choose a plan

Kanbanist BV, Keizersgracht 62, 1015 CS Amsterdam · Unsubscribe

' }, }, }, ]; @@ -752,8 +965,8 @@ const IDENTITIES: MockIdentity[] = [ // --------------------------------------------------------------------------- const addressBooks = [ - { id: 'ab-1', name: 'Persönlich', isDefault: true }, - { id: 'ab-2', name: 'Arbeit / Work', isDefault: false }, + { id: 'ab-1', name: 'Personal', isDefault: true }, + { id: 'ab-2', name: 'Work', isDefault: false }, ]; // Profile photos served straight from randomuser.me's CDN; the API at @@ -770,7 +983,7 @@ const contacts = [ phones: { p1: { number: '+49 30 8844 2200' } }, 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.' } }, + notes: { n1: { note: 'Frontend lead at EuroTech. Reviews quickly, comments at length.' } }, 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', @@ -779,7 +992,7 @@ const contacts = [ phones: { p1: { number: '+33 1 42 68 53 00' } }, 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.' } }, + notes: { n1: { note: 'Product manager. Would rather have a call than a thread.' } }, 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', @@ -788,7 +1001,7 @@ const contacts = [ phones: { p1: { number: '+39 02 7634 5678' } }, 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.' } }, + notes: { n1: { note: 'UX designer. Sends mockups as PDFs and will not be talked out of it.' } }, 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', @@ -796,7 +1009,7 @@ const contacts = [ emails: { e1: { address: 'karel@devries.example' } }, 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.' } }, + notes: { n1: { note: 'Backend developer. Filed half of our open issues, most of them valid.' } }, 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', @@ -805,7 +1018,7 @@ const contacts = [ phones: { p1: { number: '+46 8 123 456 78' } }, 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.' } }, + notes: { n1: { note: 'Tech lead in Stockholm. Nothing after 15:00 his time.' } }, 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', @@ -814,7 +1027,7 @@ const contacts = [ phones: { p1: { number: '+33 6 12 34 56 78' } }, 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.' } }, + notes: { n1: { note: 'Backend developer, remote from Paris. Overlaps with Stockholm until 17:00.' } }, 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', @@ -822,7 +1035,7 @@ const contacts = [ emails: { e1: { address: 'francesco@bianchi.example' } }, 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.' } }, + notes: { n1: { note: 'Old university friend. Runs a bookshop in Rome and still argues about type systems.' } }, 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', @@ -831,7 +1044,7 @@ const contacts = [ phones: { p1: { number: '+31 70 362 4242' } }, 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.' } }, + notes: { n1: { note: 'Solutions architect. Keeps the service diagram, ask her before drawing another one.' } }, 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', @@ -840,7 +1053,7 @@ const contacts = [ phones: { p1: { number: '+45 33 42 42 42' } }, 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.' } }, + notes: { n1: { note: 'Freelance SRE. On call for our deployment windows, invoices monthly.' } }, 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', @@ -849,7 +1062,7 @@ const contacts = [ phones: { p1: { number: '+33 1 44 27 42 42' } }, 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.' } }, + notes: { n1: { note: 'Professor of computer science. Works on formal verification of mail protocols.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } }, }, // --- Work address book --- @@ -859,7 +1072,7 @@ const contacts = [ phones: { p1: { number: '+33 1 53 67 42 00' } }, 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.' } }, + notes: { n1: { note: 'Contract and IP law. Bills in six-minute units, so keep the email short.' } }, 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', @@ -868,7 +1081,7 @@ const contacts = [ phones: { p1: { number: '+49 30 450 570 000' } }, 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.' } }, + notes: { n1: { note: 'Organises the Berlin team evenings. Books everything three months ahead.' } }, 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', @@ -877,7 +1090,7 @@ const contacts = [ phones: { p1: { number: '+353 1 677 4242' } }, 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.' } }, + notes: { n1: { note: 'Finance lead in Dublin. Wants the numbers before the meeting, not during it.' } }, 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', @@ -886,16 +1099,16 @@ const contacts = [ phones: { p1: { number: '+34 91 420 4242' } }, 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.' } }, + notes: { n1: { note: 'Brand designer. Owns the Figma library, ask before inventing a shade.' } }, 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' }] }, emails: { e1: { address: 'nils@digitaal.example' } }, - phones: { p1: { number: '+31 20 624 1337' } }, + phones: { p1: { number: '+31 20 624 8815' } }, 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.' } }, + notes: { n1: { note: 'Platform engineer. Knows where the old DNS records are buried.' } }, 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', @@ -904,7 +1117,7 @@ const contacts = [ phones: { p1: { number: '+48 22 505 4242' } }, 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.' } }, + notes: { n1: { note: 'Marketing strategist. Runs the campaign reporting.' } }, 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', @@ -913,7 +1126,7 @@ const contacts = [ phones: { p1: { number: '+353 86 123 4242' } }, 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.' } }, + notes: { n1: { note: 'Runs the Dublin office fit-out. Reachable by phone, not by email.' } }, 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', @@ -922,7 +1135,7 @@ const contacts = [ phones: { p1: { number: '+351 21 342 4242' } }, 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.' } }, + notes: { n1: { note: 'Media consultant. Handles press for the Lisbon launch.' } }, 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', @@ -931,7 +1144,7 @@ const contacts = [ phones: { p1: { number: '+32 2 555 4242' } }, 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.' } }, + notes: { n1: { note: 'Strategy consultant in Brussels. Good on procurement questions.' } }, 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', @@ -941,7 +1154,7 @@ const contacts = [ organizations: { o1: { name: 'Lindgren Consulting' } }, 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.' } }, + notes: { n1: { note: 'Data protection consultant. Reviewed our privacy notice in January.' } }, media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } }, }, // --- Groups --- @@ -967,11 +1180,11 @@ const contacts = [ // --------------------------------------------------------------------------- const mockCalendars = [ - { id: 'cal-1', name: 'Persönlich', color: '#4285f4', isVisible: true, isDefault: true }, - { id: 'cal-2', name: 'Arbeit', color: '#0b8043', isVisible: true, isDefault: false }, + { id: 'cal-1', name: 'Personal', color: '#4285f4', isVisible: true, isDefault: true }, + { id: 'cal-2', name: 'Work', color: '#0b8043', isVisible: true, isDefault: false }, { id: 'cal-3', name: 'Team', color: '#8e24aa', isVisible: true, isDefault: false }, - { id: 'cal-4', name: 'Feiertage (EU)', color: '#f4511e', isVisible: true, isDefault: false }, - { id: 'cal-5', name: 'Geburtstage', color: '#e67c73', isVisible: true, isDefault: false }, + { id: 'cal-4', name: 'Public holidays', color: '#f4511e', isVisible: true, isDefault: false }, + { id: 'cal-5', name: 'Birthdays', color: '#e67c73', isVisible: true, isDefault: false }, ]; function makeEvent( @@ -1015,41 +1228,41 @@ const calendarEvents = [ participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), }, alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } }, }), makeEvent('evt-002', 'cal-2', 'Sprint Planning', localDateTime(1, 10, 30), 'PT1H30M', { - location: 'Konferenzsaal A', + location: 'Room A', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), }, recurrence: [{ frequency: 'weekly', byDay: [{ day: 'mo' }], interval: 2 }], alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' }, action: 'display' } }, }), - makeEvent('evt-003', 'cal-2', '1:1 with Lars', localDateTime(0, 14, 0), 'PT42M', { + makeEvent('evt-003', 'cal-2', '1:1 with Lars', localDateTime(0, 14, 0), 'PT30M', { virtualLocations: { vl1: { uri: 'https://meet.example/lars-dev', name: 'Zoom' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), }, - description: 'Weekly catch-up. Duration: exactly 42 minutes - the answer to everything.', + description: 'Weekly catch-up.', }), makeEvent('evt-004', 'cal-2', 'Code Review Session', localDateTime(0, 16, 0), 'PT1H', { - location: 'Konferenzsaal B', + location: 'Room B', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), }, - description: 'Review JMAP-342 contact import PR.', + description: 'Walk through the vCard import PR, mainly the merge dialogue.', }), makeEvent('evt-005', 'cal-2', 'Architecture Review', localDateTime(2, 11, 0), 'PT1H30M', { - location: 'Konferenzsaal A', + location: 'Room A', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Astrid van der Berg', 'astrid@berglabs.example'), @@ -1063,13 +1276,13 @@ const calendarEvents = [ virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), - p2: participant('Sophie Example', 'sophie@eurotech.example'), + p2: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'), }, - description: 'Discuss API rate limit escalation for EuroTech enterprise account.', + description: 'Rate limit escalation on the EuroTech account, ticket #4521.', }), makeEvent('evt-007', 'cal-2', 'Deployment Window', localDateTime(3, 22, 0), 'PT2H', { - description: 'Production deployment: JMAP calendar integration v2.3.\nRollback plan in Confluence.\nOn-call: Henrik Nielsen.', + description: 'Calendar integration v2.3 goes to production. The rollback plan is in the runbook, Henrik is on call.', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Henrik Nielsen', 'henrik@nielsen-konsult.example'), @@ -1080,7 +1293,7 @@ const calendarEvents = [ }, }), makeEvent('evt-008', 'cal-2', 'Q1 Budget Review', localDateTime(3, 14, 0), 'PT1H', { - location: 'Konferenzsaal B', + location: 'Room B', participants: { p1: participant('Liam Ó Donaill', 'liam.odonaill@finanz.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), @@ -1088,12 +1301,12 @@ const calendarEvents = [ }, }), makeEvent('evt-009', 'cal-2', 'Retro & Demo', localDateTime(4, 15, 0), 'PT1H30M', { - location: 'Konferenzsaal A', + location: 'Room A', virtualLocations: { vl1: { uri: 'https://meet.example/retro', name: 'Google Meet' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', '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'), @@ -1105,25 +1318,25 @@ const calendarEvents = [ participants: { p1: participant('Dev User', 'dev@localhost'), p2: participant('María García', 'maria@garcia-design.example', 'owner'), - p3: participant('Sophie Example', 'sophie@eurotech.example'), + p3: participant('Sophie Müller', 'sophie@eurotech.example'), }, }), makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Stripe API v2023-10 deprecated. Must be on v2025-01 by today.', + description: 'Payment API v2023-10 stops answering today. Both keys have to be on v2025-01.', color: '#d50000', }), // ===== Team calendar (cal-3) - social & team ===== - makeEvent('evt-012', 'cal-3', 'Biergarten Abend 🍺', localDateTime(5, 18, 0), 'PT3H', { - location: 'Biergarten am Prinsengracht, Amsterdam', - description: 'Monthly team social. Bretzel buffet included.\nVegetarian options: Käsespätzle, Kartoffelsalat.\nBring your own Dirndl/Lederhosen (optional but encouraged).', + makeEvent('evt-012', 'cal-3', 'Team evening', localDateTime(5, 18, 0), 'PT3H', { + location: 'Restaurante Fado, Zeedijk 62, Amsterdam', + description: 'Set menu, paid by the company. Vegetarian option has to be flagged by Wednesday.', participants: { p1: participant('Katrin Bauer', 'katrin.bauer@charite.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), p3: participant('Pierre Dubois', 'pierre@dubois.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'), - p5: participant('Sophie Example', 'sophie@eurotech.example'), + p5: participant('Sophie Müller', 'sophie@eurotech.example'), }, }), makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', { @@ -1132,23 +1345,23 @@ 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 Example', 'sophie@eurotech.example'), + p4: participant('Sophie Müller', 'sophie@eurotech.example'), }, }), - makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', { - location: 'Kantine, 2. OG', - description: 'Presenter: Dev User\nTopic: How JMAP solves the N+1 problem and why it\'s better than IMAP for modern clients.\nPizza will be provided.', + makeEvent('evt-014', 'cal-3', 'Lunch & learn: how JMAP batches requests', localDateTime(4, 12, 0), 'PT1H', { + location: 'Canteen, second floor', + description: 'Dev User walks through batching and back-references, with the numbers from the prototype. Pizza at 12:00, talk at 12:15.', participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Astrid van der Berg', 'astrid@berglabs.example'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'), }, }), - makeEvent('evt-015', 'cal-3', 'Eurovision Watch Party 🎤✨', localDateTime(60, 20, 0), 'PT4H', { - 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.', + makeEvent('evt-015', 'cal-3', 'Quarterly all-hands', localDateTime(60, 15, 0), 'PT1H30M', { + location: 'Room A, and streamed', + description: 'Numbers, roadmap, questions. Send questions in advance if you want an answer that has been thought about.', participants: { - p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'), + p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), p3: participant('Pierre Dubois', 'pierre@dubois.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'), @@ -1156,9 +1369,9 @@ const calendarEvents = [ p6: participant('Nils Andersson', 'nils@digitaal.example'), }, }), - makeEvent('evt-016', 'cal-3', 'Cooking Class - Pasta Fresca', localDateTime(12, 18, 30), 'PT2H30M', { - location: 'La Cucina Cooking School, Jordaan, Amsterdam', - description: 'Team cooking class: fresh pasta from scratch.\nMenu: tagliatelle al ragù, ravioli ricotta e spinaci.\nChef: Chiara Rossi (guest instructor)', + makeEvent('evt-016', 'cal-3', 'Pasta course', localDateTime(12, 18, 30), 'PT2H30M', { + location: 'La Cucina, Jordaan, Amsterdam', + description: 'Three hours, you eat what you make. Chiara is teaching, which she volunteered for and may come to regret.', participants: { p1: participant('Chiara Rossi', 'chiara@rossi.example', 'owner'), p2: participant('Dev User', 'dev@localhost'), @@ -1169,27 +1382,27 @@ const calendarEvents = [ // ===== Personal calendar (cal-1) ===== makeEvent('evt-017', 'cal-1', 'Fika with Nils', localDateTime(2, 15, 30), 'PT1H', { - location: 'Café de Flore, Prinsengracht, Amsterdam', - description: 'Catch-up over coffee and kanelbullar.', + location: 'Koffiehuis Prinsengracht, Amsterdam', + description: 'Catch-up over coffee.', }), makeEvent('evt-018', 'cal-1', 'Lake Como Weekend', localDateTime(14, 10, 0), 'P2D', { location: 'Villa sul Lago, Bellagio, Lake Como', - description: 'Weekend getaway.\nConfirmation: EU42GDPR\nCheck-in: 15:00\nCheck-out: 11:00', + description: 'Reservation LS-4419-BG.\nCheck-in from 15:00, check-out by 11:00.\nThe key box code is in the voucher.', showWithoutTime: true, }), makeEvent('evt-019', 'cal-1', 'Tandarts (Dentist)', localDateTime(7, 9, 30), 'PT45M', { location: 'Tandartspraktijk Centrum, Reguliersgracht 12, Amsterdam', - description: 'Regular check-up. Don\'t forget to floss!', + description: 'Six-month check-up.', alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display' } }, }), makeEvent('evt-020', 'cal-1', 'Albert Cuyp Markt', localDateTime(6, 10, 0), 'PT2H', { location: 'Albert Cuypstraat, Amsterdam', - description: 'Saturday market run.\nShopping list: stroopwafels, Gouda, tulips, fresh bread, olives.', + description: 'Market run. Bread, cheese, olives, and whatever looks good.', showWithoutTime: false, }), makeEvent('evt-021', 'cal-1', 'Cycling to Vondelpark', localDateTime(6, 14, 0), 'PT1H30M', { location: 'Vondelpark, Amsterdam', - description: 'Afternoon bike ride. Meet at the main entrance.', + description: 'Meet at the main entrance.', }), makeEvent('evt-022', 'cal-1', 'Yoga Class', localDateTime(0, 7, 0), 'PT1H', { location: 'De Nieuwe Yogaschool, Laurierstraat, Amsterdam', @@ -1198,7 +1411,7 @@ const calendarEvents = [ makeEvent('evt-023', 'cal-1', 'Dutch Language Lesson', localDateTime(1, 19, 0), 'PT1H30M', { location: 'Taleninstituut, Plantage Middenlaan, Amsterdam', recurrence: [{ frequency: 'weekly', byDay: [{ day: 'tu' }] }], - description: 'Semester 3 - past tense and separable verbs. Ik heb geprobeerd...', + description: 'Semester 3: past tense and separable verbs.', }), makeEvent('evt-024', 'cal-1', 'Call with Mum', localDateTime(0, 18, 30), 'PT30M', { recurrence: [{ frequency: 'weekly', byDay: [{ day: 'su' }] }], @@ -1207,65 +1420,64 @@ const calendarEvents = [ // Overlapping personal events makeEvent('evt-025', 'cal-1', 'Haircut', localDateTime(6, 14, 30), 'PT45M', { location: 'Kapper de Luxe, Utrechtsestraat, Amsterdam', - description: 'Overlaps with Vondelpark bike ride - need to reschedule one!', + description: 'Overlaps with the bike ride. One of them has to move.', }), // ===== Holiday calendar (cal-4) - all-day events ===== - makeEvent('evt-026', 'cal-4', 'Koningsdag 🧡', localDateTime(42, 0, 0), 'P1D', { + makeEvent('evt-026', 'cal-4', 'Koningsdag', localDateTime(42, 0, 0), 'P1D', { showWithoutTime: true, - description: 'King\'s Day - national holiday in the Netherlands.\nWear orange! Visit a vrijmarkt. Eat tompouce.', + description: 'Public holiday in the Netherlands. Shops shut, the city centre is closed to cars.', color: '#ff6d00', }), makeEvent('evt-027', 'cal-4', 'Tag der Arbeit', localDateTime(48, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Labour Day - public holiday in most EU countries.', + description: 'Public holiday in most of Europe. Stockholm and Berlin are closed.', }), - makeEvent('evt-028', 'cal-4', 'Europe Day 🇪🇺', localDateTime(55, 0, 0), 'P1D', { + makeEvent('evt-028', 'cal-4', 'Hemelvaartsdag', localDateTime(55, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Anniversary of the Schuman Declaration (1950). The foundation of European integration.', - color: '#003399', + description: 'Public holiday in the Netherlands. Most people take the Friday as well.', }), makeEvent('evt-029', 'cal-4', 'Bevrijdingsdag', localDateTime(52, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Liberation Day - Dutch national holiday commemorating the end of WWII occupation.', + description: 'Liberation Day. A holiday for us, not for every employer in the country.', }), // ===== Birthday calendar (cal-5) ===== - makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', { + makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'Don\'t forget to bring Kuchen!', + description: 'She has said twice that she wants nothing. Bring cake anyway.', }), makeEvent('evt-031', 'cal-5', '🎂 Chiara Rossi', localDateTime(21, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'She prefers tiramisu over cake.', + description: 'Tiramisu, not cake.', }), makeEvent('evt-032', 'cal-5', '🎂 Pierre Dubois', localDateTime(45, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'Likes a good Bordeaux.', + description: 'Wine, and he will notice which one.', }), makeEvent('evt-033', 'cal-5', '🎂 Lars Johansson', localDateTime(-3, 0, 0), 'P1D', { showWithoutTime: true, recurrence: [{ frequency: 'yearly' }], - description: 'Just passed! Hope you remembered.', + description: 'Was last week. It was not remembered.', }), // ===== JMAP Conf & travel (work calendar) ===== makeEvent('evt-034', 'cal-2', 'JMAP Conf Amsterdam', localDateTime(28, 9, 0), 'P2D', { location: 'RAI Amsterdam Convention Centre', showWithoutTime: true, - description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!', + description: 'Your talk is day one, 14:00, main hall. Slides as PDF to the organisers by the Friday before.', participants: { p1: participant('Dev User', 'dev@localhost'), - p2: participant('Sophie Example', 'sophie@eurotech.example'), + p2: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'), }, }), makeEvent('evt-035', 'cal-2', 'FOSDEM Talk Prep', localDateTime(10, 13, 0), 'PT2H', { virtualLocations: { vl1: { uri: 'https://meet.example/fosdem-prep', name: 'Meet' } }, - description: 'Rehearse FOSDEM 2027 talk proposal.\nTitle: "Modern Email Clients with JMAP - From Protocol to Production"', + description: 'Run through the FOSDEM proposal end to end and cut it down to 30 minutes.', }), ]; @@ -1826,6 +2038,35 @@ function resolveBackReferences( }); } +// --------------------------------------------------------------------------- +// Request limits +// --------------------------------------------------------------------------- + +// Stalwart's defaults. Too many method calls fails the request whole +// (RFC 8620 §3.6.1), an over-sized /get or /set fails that call +// (`requestTooLarge`, §5.1 and §5.3). The mock enforces what it advertises so a +// client that sends an unsplit batch fails here the way it fails in production. +const MAX_CALLS_IN_REQUEST = 16; +const MAX_OBJECTS_IN_GET = 500; +const MAX_OBJECTS_IN_SET = 500; + +/** Objects a /set call touches, across all three of its maps (RFC 8620 §5.3). */ +function setObjectCount(args: MethodArgs): number { + const size = (value: unknown) => (Array.isArray(value) ? value.length : Object.keys(value || {}).length); + return size(args.create) + size(args.update) + size(args.destroy); +} + +/** The method-level error a server returns for an over-sized /get or /set. */ +function tooLargeFor(method: string, args: MethodArgs, callId: string): MethodResult | null { + if (method.endsWith('/get') && Array.isArray(args.ids) && args.ids.length > MAX_OBJECTS_IN_GET) { + return ['error', { type: 'requestTooLarge', description: `More than ${MAX_OBJECTS_IN_GET} ids in ${method}` }, callId]; + } + if (method.endsWith('/set') && setObjectCount(args) > MAX_OBJECTS_IN_SET) { + return ['error', { type: 'requestTooLarge', description: `More than ${MAX_OBJECTS_IN_SET} objects in ${method}` }, callId]; + } + return null; +} + // --------------------------------------------------------------------------- // Route handlers // --------------------------------------------------------------------------- @@ -1858,9 +2099,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, - maxCallsInRequest: 16, - maxObjectsInGet: 500, - maxObjectsInSet: 500, + maxCallsInRequest: MAX_CALLS_IN_REQUEST, + maxObjectsInGet: MAX_OBJECTS_IN_GET, + maxObjectsInSet: MAX_OBJECTS_IN_SET, collationAlgorithms: ['i;ascii-casemap', 'i;ascii-numeric', 'i;unicode-casemap'], }, 'urn:ietf:params:jmap:mail': {}, @@ -2006,6 +2247,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid request: missing methodCalls' }, { status: 400 }); } + if (methodCalls.length > MAX_CALLS_IN_REQUEST) { + return NextResponse.json({ + type: 'urn:ietf:params:jmap:error:limit', + status: 400, + limit: 'maxCallsInRequest', + detail: `This request contains ${methodCalls.length} method calls, the maximum is ${MAX_CALLS_IN_REQUEST}.`, + }, { status: 400 }); + } + const responses: MethodResult[] = []; // Process method calls sequentially (to support back-references) @@ -2015,8 +2265,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Use resolved args if available, otherwise original const args = i < resolved.length ? resolved[i][1] : methodCalls[i][1]; + const tooLarge = tooLargeFor(method, args, callId); const handler = METHOD_HANDLERS[method]; - if (handler) { + if (tooLarge) { + responses.push(tooLarge); + } else if (handler) { const result = handler(args, callId); responses.push(result); } else { diff --git a/app/globals.css b/app/globals.css index 545a415d..c069a56c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "tw-animate-css"; @custom-variant dark (&:where(.dark, .dark *)); @@ -558,6 +559,40 @@ body { overscroll-behavior: none; } +/* Shake animation (for rejected input) */ +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-4px); + } + 40%, + 80% { + transform: translateX(4px); + } +} + +.animate-shake { + animation: shake 0.4s ease-in-out; +} + +/* Fade in animation (for popovers) */ +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out; +} + /* Slide in from right animation (for mobile views) */ @keyframes slide-in-from-right { from { diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 75f4375b..6977cd68 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -102,4 +102,98 @@ describe('ContactForm', () => { expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })]) ); }); + + it('saves an organization-only card when the organization type is selected', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('type_organization')); + fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + + const savedData = onSave.mock.calls[0][0]; + expect(savedData.kind).toBe('org'); + expect(savedData.organizations.o0.name).toBe('Acme Corp'); + // No personal name components; the org name carries the display name instead. + expect(savedData.name.components).toBeUndefined(); + expect(savedData.name.full).toBe('Acme Corp'); + }); + + it('hides the personal name fields in organization mode', () => { + render(); + expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('type_organization')); + + expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument(); + // The organization field moves into the identity section, so it appears once. + expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1); + }); + + it('still requires a name in organization mode', async () => { + const onSave = vi.fn(); + render(); + + fireEvent.click(screen.getByText('type_organization')); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(screen.getByText('name_required')).toBeInTheDocument(); + }); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('accepts an organization instead of a personal name in person mode', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('section_work')); + fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp'); + }); + + it('opens an existing org card in organization mode', () => { + const orgContact: ContactCard = { + id: '2', + addressBookIds: {}, + kind: 'org', + name: { full: 'Acme Corp' }, + organizations: { o0: { name: 'Acme Corp' } }, + }; + render(); + + expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument(); + }); + + it('switches an org card back to a person', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const orgContact: ContactCard = { + id: '2', + addressBookIds: {}, + kind: 'org', + name: { full: 'Acme Corp' }, + organizations: { o0: { name: 'Acme Corp' } }, + }; + render(); + + fireEvent.click(screen.getByText('type_person')); + fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + expect(onSave.mock.calls[0][0].kind).toBe('individual'); + }); }); diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index c4160b7e..1b07d86e 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const hasNickname = nicknames.length > 0; const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined; - const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[]; + // On an organization card the org name is already the heading; don't repeat it. + const orgName = orgs[0]?.name; + const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[]; const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0; const hasWork = titles.length > 0 || orgs.length > 0; const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns)); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index a2e898eb..566d5301 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : "" ); + // A card may describe an organization instead of a person (RFC 9553 kind "org"). + // Older cards predate the explicit kind, so fall back to "has an org name but no + // personal name". + const [isOrg, setIsOrg] = useState(() => { + if (!contact) return false; + if (contact.kind) return contact.kind === "org"; + const hasPersonName = !!(findComponent("given") || findComponent("surname")); + return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name; + }); + const [jobTitle, setJobTitle] = useState(() => { if (contact?.titles) { const t = Object.values(contact.titles).find(t => t.kind !== "role"); @@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress e.preventDefault(); setError(null); - if (!givenName.trim() && !surname.trim()) { + // An organization name identifies the card just as well as a personal name. + const orgName = organization.trim(); + const hasPersonName = !!(givenName.trim() || surname.trim()); + if (isOrg ? !orgName : (!hasPersonName && !orgName)) { setError(t("name_required")); return; } @@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress // Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly. const nameComponents = []; - if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() }); - if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); - if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() }); - if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); - if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() }); + if (!isOrg) { + if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() }); + if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); + if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() }); + if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); + if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() }); + } + + // Without personal name components, carry the organization name in `name.full` + // so servers and other clients have something to display. + const nameValue: ContactCard["name"] = nameComponents.length > 0 + ? { components: nameComponents, isOrdered: true } + : { full: orgName }; const titlesMap: Record = {}; if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" }; @@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const mediaValue: Record | null | undefined = Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined); + // Only send `kind` when this form owns the answer: switching a card between + // person and organization. Leave other kinds (group, location, ...) untouched. + const kindValue: ContactCard["kind"] | undefined = + isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined); + const data: Partial = { - name: { components: nameComponents, isOrdered: true }, + name: nameValue, + ...(kindValue ? { kind: kindValue } : {}), nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined, phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined, titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined, - organizations: organization.trim() - ? { o0: { name: organization.trim(), units: orgUnits } } + organizations: orgName + ? { o0: { name: orgName, units: orgUnits } } : undefined, addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined, onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined, @@ -570,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress } }; - const previewName = [givenName, surname].filter(Boolean).join(" ").trim(); + const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim(); const previewEmail = emails.find(e => e.address.trim())?.address.trim() || ""; return ( @@ -661,38 +688,81 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress )} -
-
- - setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> -
-
- - setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus /> -
-
- - setSurname(e.target.value)} placeholder={t("surname")} /> -
-
- - setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> +
+ {t("contact_type")} +
+ {[ + { org: false, label: t("type_person"), icon: User }, + { org: true, label: t("type_organization"), icon: Building }, + ].map(({ org, label, icon: Icon }) => ( + + ))}
-
-
- - setAdditionalName(e.target.value)} placeholder={t("middle_name")} /> + {isOrg ? ( +
+
+ + setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus /> +
+
+ + setNickname(e.target.value)} placeholder={t("nickname_placeholder")} /> +
-
- - setNickname(e.target.value)} placeholder={t("nickname_placeholder")} /> -
-
+ ) : ( + <> +
+
+ + setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> +
+
+ + setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus /> +
+
+ + setSurname(e.target.value)} placeholder={t("surname")} /> +
+
+ + setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> +
+
+
+
+ + setAdditionalName(e.target.value)} placeholder={t("middle_name")} /> +
+
+ + setNickname(e.target.value)} placeholder={t("nickname_placeholder")} /> +
+
+ + )} {/* Email */} @@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress {/* Work & Organization */} - +
-
- - setOrganization(e.target.value)} placeholder={t("organization_placeholder")} /> -
+ {/* In organization mode the org name is the card's identity, edited above. */} + {!isOrg && ( +
+ + setOrganization(e.target.value)} placeholder={t("organization_placeholder")} /> +
+ )}
setDepartment(e.target.value)} placeholder={t("department_placeholder")} /> diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx deleted file mode 100644 index 72d41e35..00000000 --- a/components/email/__tests__/email-list-item.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { render, screen, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { EmailListItem } from '../email-list-item'; -import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; -import { useEmailStore } from '@/stores/email-store'; -import type { Email } from '@/lib/jmap/types'; - -// Mock the drag hook -vi.mock('@/hooks/use-email-drag', () => ({ - useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), -})); - -// Mock identity badge -vi.mock('../email-identity-badge', () => ({ - EmailIdentityBadge: () => null, -})); - -// Mock auth store -vi.mock('@/stores/auth-store', () => ({ - useAuthStore: () => ({ identities: [] }), -})); - -const makeEmail = (overrides: Partial = {}): Email => ({ - id: 'email-1', - threadId: 'thread-1', - mailboxIds: { inbox: true }, - keywords: { $seen: true }, - size: 1000, - receivedAt: '2024-01-15T10:00:00Z', - from: [{ name: 'Alice', email: 'alice@example.com' }], - subject: 'Test Subject', - hasAttachment: false, - ...overrides, -}); - -describe('EmailListItem tag badge', () => { - beforeEach(() => { - useSettingsStore.setState({ - emailKeywords: [...DEFAULT_KEYWORDS], - showPreview: false, - mailLayout: 'split', - }); - useEmailStore.setState({ - selectedEmailIds: new Set(), - selectedMailbox: 'inbox', - }); - }); - - it('does not show tag badge when email has no label keyword', () => { - const email = makeEmail({ keywords: { $seen: true } }); - render(); - expect(screen.getByText('Test Subject')).toBeInTheDocument(); - // No keyword label should appear - DEFAULT_KEYWORDS.forEach((kw) => { - expect(screen.queryByText(kw.label)).not.toBeInTheDocument(); - }); - }); - - it('shows tag badge with label when email has $label: keyword', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); - render(); - expect(screen.getByText('Red')).toBeInTheDocument(); - }); - - it('shows tag badge for legacy $color: keyword', () => { - const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } }); - render(); - expect(screen.getByText('Blue')).toBeInTheDocument(); - }); - - it('shows a gray fallback badge when keyword id is not in settings', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }); - render(); - // Unknown tags fall back to the raw id as label with a gray dot - // (see email-list-item.tsx: keywordDefs fallback). - expect(screen.getByText('unknown-tag')).toBeInTheDocument(); - }); - - it('shows custom keyword label', () => { - useSettingsStore.setState({ - emailKeywords: [ - ...DEFAULT_KEYWORDS, - { id: 'work', label: 'Work', color: 'teal' }, - ], - }); - const email = makeEmail({ keywords: { $seen: true, '$label:work': true } }); - render(); - expect(screen.getByText('Work')).toBeInTheDocument(); - }); - - it('updates badge when keyword definition changes', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); - const { rerender } = render(); - expect(screen.getByText('Red')).toBeInTheDocument(); - - // Update label name - act(() => { - useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' }); - }); - rerender(); - expect(screen.getByText('Urgent')).toBeInTheDocument(); - expect(screen.queryByText('Red')).not.toBeInTheDocument(); - }); - - it('renders subject even without tag', () => { - const email = makeEmail({ subject: 'Hello World' }); - render(); - expect(screen.getByText('Hello World')).toBeInTheDocument(); - }); - - it('renders inline preview text in focused mail layout', () => { - useSettingsStore.setState({ - showPreview: true, - mailLayout: 'focus', - }); - const email = makeEmail({ preview: 'Inline preview content' }); - const { container } = render(); - - expect(screen.getByText('Test Subject')).toBeInTheDocument(); - expect(screen.getByText(/Inline preview content/)).toBeInTheDocument(); - expect(container.querySelector('p')).toBeNull(); - }); -}); - -describe('EmailListItem shift-range checkbox', () => { - beforeEach(() => { - useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' }); - }); - - it('shift-clicking the checkbox extends the selection from the anchor', () => { - const e1 = makeEmail({ id: 'e1', threadId: 't1' }); - const e2 = makeEmail({ id: 'e2', threadId: 't2' }); - const e3 = makeEmail({ id: 'e3', threadId: 't3' }); - // selection mode active (so the checkbox renders), anchor on e1 - useEmailStore.setState({ - emails: [e1, e2, e3], - selectedEmailIds: new Set(['e1']), - lastSelectedEmailId: 'e1', - selectedMailbox: 'inbox', - }); - - render(); - // the checkbox is the first button in the row (shown in selection mode) - const checkbox = screen.getAllByRole('button')[0]; - act(() => { - checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); - }); - - const sel = useEmailStore.getState().selectedEmailIds; - expect(sel.has('e1')).toBe(true); - expect(sel.has('e2')).toBe(true); // the in-between row got filled in - expect(sel.has('e3')).toBe(true); - }); -}); diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx new file mode 100644 index 00000000..f009b310 --- /dev/null +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -0,0 +1,228 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ───────────────── + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ ref: { current: null } }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me' }, + { id: 'id-info', email: 'info@example.com', name: 'Info' }, + ], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onSend: { call: async () => [] }, + beforeSend: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + }, + contactHooks: { + search: { call: async () => [] }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: () => {} })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const RECEIVED = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'me@example.com', name: 'Me' }, { email: 'carol@other.com', name: 'Carol' }], + cc: [{ email: 'dave@other.com', name: 'Dave' }], + subject: 'Hello', +}; + +/** The same conversation, but the message opened is the one we sent back. */ +const SELF_SENT = { + from: [{ email: 'me@example.com', name: 'Me' }], + to: [{ email: 'bob@other.com', name: 'Bob' }], + cc: [{ email: 'carol@other.com', name: 'Carol' }], + subject: 'Re: Hello', +}; + +/** Chip labels currently shown in a recipient row, in order. Chips are the + * draggable spans inside the row; next-intl is mocked to return the key, so + * the Cc row is found via its "cc_label" caption. */ +const chipsIn = (row: HTMLElement) => + Array.from(row.querySelectorAll('[draggable]')).map((el) => el.textContent?.trim()); + +const toChips = () => chipsIn(screen.getByTestId('composer-to')); +const ccChips = () => chipsIn(screen.getByText('cc_label').parentElement as HTMLElement); + +const identitySelect = () => screen.getByTestId('composer-from') as HTMLSelectElement; + +describe('composer reply addressing', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('addresses a reply to the sender of a received message', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + }); + + it('reply-all keeps the other recipients but not our own address', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']); + expect(ccChips()).toEqual(['Dave (dave@other.com)']); + }); + + // #703: replying to our own message inside a thread used to address the + // reply back to ourselves instead of continuing the conversation. + it('addresses a reply to our own message to the original recipient', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + }); + + it('reply-all on our own message restores the original To and Cc', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + expect(ccChips()).toEqual(['Carol (carol@other.com)']); + }); + + it('sends the reply to our own message from the identity that sent it', () => { + render(); + expect(identitySelect().value).toBe('id-info'); + }); +}); diff --git a/components/email/__tests__/tag-badge.test.tsx b/components/email/__tests__/tag-badge.test.tsx new file mode 100644 index 00000000..70775ff6 --- /dev/null +++ b/components/email/__tests__/tag-badge.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TagBadge } from '../tag-badge'; +import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store'; + +const TAGS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, +]; + +describe('TagBadge', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + it('names the tag by its full path', () => { + render(); + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('names a tag it has no definition for by its id', () => { + render(); + expect(screen.getByText('from-elsewhere')).toBeInTheDocument(); + }); + + it('offers removal only when asked to', () => { + const onRemove = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'remove_tag' })); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it('leaves the dot alone, having nowhere to put the control', () => { + render( {}} />); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Work')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/tag-picker.test.tsx b/components/email/__tests__/tag-picker.test.tsx new file mode 100644 index 00000000..048d9096 --- /dev/null +++ b/components/email/__tests__/tag-picker.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TagPicker } from '../tag-picker'; +import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store'; + +const TAGS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + { id: 'work/clients/acme', label: 'Acme', color: 'red' }, + { id: 'personal', label: 'Personal', color: 'purple' }, +]; + +/** Ten tags is the point at which the filter box appears. */ +const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({ + id: `tag-${i}`, + label: i === 0 ? 'Invoices' : `Tag ${i}`, + color: 'blue', +})); + +describe('TagPicker', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + it('names a nested tag by its own label, not the whole path', () => { + render( {}} />); + + // The tree conveys the hierarchy, so a child needs only its own name. + expect(screen.getByText('Clients')).toBeInTheDocument(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument(); + }); + + it('indents each level below its parent', () => { + const { container } = render( {}} />); + const acme = screen.getByText('Acme'); + + // Two levels down: two nested indent wrappers between it and the list. + const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4'); + expect(indents).not.toBeNull(); + expect(container.querySelectorAll('.ps-4').length).toBe(2); + }); + + it('marks the applied tags and reports toggles by id', () => { + const onToggle = vi.fn(); + render(); + + const row = screen.getByText('Clients').closest('button')!; + expect(row).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(row); + expect(onToggle).toHaveBeenCalledWith('work/clients'); + }); + + it('lists a tag it has no definition for, so it can be taken off', () => { + const onToggle = vi.fn(); + const { rerender } = render(); + + const row = screen.getByText('from-elsewhere').closest('button')!; + expect(row).toHaveAttribute('aria-checked', 'true'); + + fireEvent.click(row); + expect(onToggle).toHaveBeenCalledWith('from-elsewhere'); + + // Nothing but the message says it exists, so deselecting is the last of it. + rerender(); + expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument(); + }); + + it('counts undefined tags towards the filter box, and matches them', () => { + const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } }); + expect(within(container).getByText('stray-3')).toBeInTheDocument(); + expect(within(container).queryByText('Work')).not.toBeInTheDocument(); + }); + + it('hides the filter box until the list is long enough to need one', () => { + render( {}} />); + expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument(); + + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + render( {}} />); + expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0); + }); + + it('flattens to matches while filtering, and says so when there are none', () => { + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } }); + expect(within(container).getByText('Invoices')).toBeInTheDocument(); + expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } }); + expect(within(container).getByText('tag_no_matches')).toBeInTheDocument(); + }); + + it('matches the full path, so a child is reachable by its parent name', () => { + useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] }); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } }); + // Filtered rows are flat, so they carry the whole path. + expect(within(container).getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('lists tags flat when nesting is off', () => { + useSettingsStore.setState({ nestedTags: false }); + const { container } = render( {}} />); + + expect(container.querySelectorAll('.ps-4').length).toBe(0); + expect(screen.getByText('Clients')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx new file mode 100644 index 00000000..c0935c26 --- /dev/null +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -0,0 +1,290 @@ +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: () => ({ identities: [] }), +})); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + hasAttachment: false, + ...overrides, +}); + +/** + * A one-message thread, built through the real grouping so the fixture cannot + * drift from what the list actually feeds this component. `ThreadListItem` + * delegates to `SingleEmailItem` at that size, which is what draws every + * single-message row in the app. + */ +function renderRow(email: Email) { + const [thread] = groupEmailsByThread([email]); + return render( + {}} + onEmailSelect={() => {}} + />, + ); +} + +describe('ThreadListItem tag badge', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + it('does not show a tag badge when the email has no label keyword', () => { + renderRow(makeEmail({ keywords: { $seen: true } })); + + expect(screen.getByText('Test Subject')).toBeInTheDocument(); + DEFAULT_KEYWORDS.forEach((kw) => { + expect(screen.queryByText(kw.label)).not.toBeInTheDocument(); + }); + }); + + it('shows a tag badge for a $label: keyword', () => { + renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + + expect(screen.getByText('Red')).toBeInTheDocument(); + }); + + it('shows a tag badge for the legacy $color: keyword', () => { + renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } })); + + expect(screen.getByText('Blue')).toBeInTheDocument(); + }); + + it('falls back to the raw id when the tag is not in settings', () => { + // A keyword created by another client, or one whose definition was deleted. + renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } })); + + expect(screen.getByText('unknown-tag')).toBeInTheDocument(); + }); + + it('shows a custom tag label', () => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }], + }); + renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } })); + + expect(screen.getByText('Work')).toBeInTheDocument(); + }); + + it('follows a renamed tag definition', () => { + const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); + const { rerender } = renderRow(email); + expect(screen.getByText('Red')).toBeInTheDocument(); + + act(() => { + useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' }); + }); + const [thread] = groupEmailsByThread([email]); + rerender( + {}} + onEmailSelect={() => {}} + />, + ); + + expect(screen.getByText('Urgent')).toBeInTheDocument(); + expect(screen.queryByText('Red')).not.toBeInTheDocument(); + }); +}); + +describe('ThreadListItem multi-message thread', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + function renderThread(emails: Email[], expanded = false) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} + onEmailSelect={() => {}} + />, + ); + } + + it('carries the tags of every message, not just the first', () => { + // A collapsed row stands in for the whole thread, so a tag applied only to + // a later message still has to surface. + renderThread([ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }), + ]); + + expect(screen.getByText('Red')).toBeInTheDocument(); + expect(screen.getByText('Blue')).toBeInTheDocument(); + }); + + it('names a tag shared by several messages once', () => { + renderThread([ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }), + ]); + + expect(screen.getAllByText('Red')).toHaveLength(1); + }); + + it('shows each message its own tags once the thread is expanded', () => { + renderThread( + [ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }), + ], + true, + ); + + // Once on the header and once on the message that carries it. + expect(screen.getAllByText('Red').length).toBeGreaterThan(1); + }); +}); + +describe('ThreadListItem row content', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + it('renders the subject without a tag', () => { + renderRow(makeEmail({ subject: 'Hello World' })); + + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('renders preview text inline in the focused layout', () => { + useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' }); + const { container } = renderRow(makeEmail({ preview: 'Inline preview content' })); + + expect(screen.getByText('Test Subject')).toBeInTheDocument(); + expect(screen.getByText(/Inline preview content/)).toBeInTheDocument(); + // Focused rows are one line: the preview shares the subject's element + // rather than getting a paragraph of its own. + expect(container.querySelector('p')).toBeNull(); + }); +}); + +describe('ThreadListItem shift-range checkbox', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + }); + + it('shift-clicking the checkbox extends the selection from the anchor', () => { + const e1 = makeEmail({ id: 'e1', threadId: 't1' }); + const e2 = makeEmail({ id: 'e2', threadId: 't2' }); + const e3 = makeEmail({ id: 'e3', threadId: 't3' }); + // Selection mode active so the checkbox renders, with the anchor on e1. + useEmailStore.setState({ + emails: [e1, e2, e3], + selectedEmailIds: new Set(['e1']), + lastSelectedEmailId: 'e1', + selectedMailbox: 'inbox', + }); + + renderRow(e3); + const checkbox = screen.getAllByRole('button')[0]; + act(() => { + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + }); + + const selected = useEmailStore.getState().selectedEmailIds; + expect(selected.has('e1')).toBe(true); + expect(selected.has('e2')).toBe(true); // the row in between got filled in + expect(selected.has('e3')).toBe(true); + }); +}); + +describe('ThreadListItem row tint', () => { + const rowClasses = (container: HTMLElement) => + container.querySelector('[data-email-id="email-1"]')!.className.split(' '); + + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + tintListRowsByTag: true, + }); + useEmailStore.setState({ + selectedEmailIds: new Set(['email-1']), + selectedMailbox: 'inbox', + }); + }); + + it('keeps a checked row tinted, and says so to either theme', () => { + const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + const classes = rowClasses(container); + + expect(classes).toContain('bg-red-50'); + expect(classes).toContain('dark:bg-red-950/30'); + expect(classes).not.toContain('bg-accent/40'); + expect(classes).toContain('ring-primary/20'); + }); + + it('washes a checked row that has no tint to keep', () => { + const { container } = renderRow(makeEmail({ keywords: { $seen: true } })); + const classes = rowClasses(container); + + expect(classes).toContain('bg-accent/40'); + expect(classes).toContain('ring-primary/20'); + }); + + it('leaves the tint alone when the setting is off', () => { + useSettingsStore.setState({ tintListRowsByTag: false }); + const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + const classes = rowClasses(container); + + expect(classes).not.toContain('bg-red-50'); + expect(classes).toContain('bg-accent/40'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index f5abe5d5..0479f183 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -36,7 +36,8 @@ 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 { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, @@ -322,31 +323,17 @@ export function EmailComposer({ const toRecipient = (r: { name?: string; email?: string }): Recipient => ({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" }); + const ownIdentityEmails = identities.map(i => i.email).filter((e): e is string => Boolean(e)); + // Initialize with reply/forward data if provided const getInitialTo = (): Recipient[] => { - if (!replyTo) return []; - // RFC 5322: use Reply-To header if present, otherwise fall back to From - const replyTarget = replyTo.replyToAddresses?.length - ? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient) - : (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []); - if (mode === 'reply') { - return replyTarget; - } else if (mode === 'replyAll') { - const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); - const originalTo = (replyTo.to ?? []) - .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) - .map(toRecipient); - return [...replyTarget, ...originalTo]; - } - return []; + if (mode !== 'reply' && mode !== 'replyAll') return []; + return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient); }; const getInitialCc = (): Recipient[] => { - if (!replyTo || mode !== 'replyAll') return []; - const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); - return (replyTo.cc ?? []) - .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) - .map(toRecipient); + if (mode !== 'replyAll') return []; + return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient); }; const getInitialSubject = () => { @@ -716,6 +703,18 @@ export function EmailComposer({ if (mode !== 'reply' && mode !== 'replyAll') return; + // Replying to our own message in a thread (#703): keep sending as the + // identity that sent it. Resolving from the recipients here would pick the + // *other* party's address - and on a catch-all domain it would even set a + // From override to their address. + if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) { + const senderIdentityId = findDraftIdentityId(identities, replyTo?.from?.[0]); + if (senderIdentityId) { + setSelectedIdentityId(senderIdentityId); + return; + } + } + const resolved = resolveReplyFrom(identities, { to: replyTo?.to, cc: replyTo?.cc, @@ -755,6 +754,7 @@ export function EmailComposer({ replyTo?.accountId, replyTo?.bcc, replyTo?.cc, + replyTo?.from, replyTo?.to, selectedIdentityId, ]); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 7cdfb1f7..2cef2b5b 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -23,8 +23,6 @@ import { Archive, FolderInput, Tag, - X, - Check, Inbox, Send, File, @@ -34,10 +32,12 @@ import { EditIcon, CalendarClock, XCircle, + Paperclip, } from "lucide-react"; -import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; +import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { TagPicker } from "./tag-picker"; interface Position { x: number; @@ -59,12 +59,13 @@ interface EmailContextMenuProps { onReply?: () => void; onReplyAll?: () => void; onForward?: () => void; + onForwardAsAttachment?: () => void; onMarkAsRead?: (read: boolean) => void; onToggleStar?: () => void; onTogglePinned?: () => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMoveToMailbox?: (mailboxId: string) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; @@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => { } }; -// Get all active label/color tag IDs from email keywords -const getCurrentColors = (keywords: Record | undefined): string[] => { - if (!keywords) return []; - const tags: string[] = []; - for (const key of Object.keys(keywords)) { - if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - tags.push( - key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) - ); - } - } - return tags; -}; - export function EmailContextMenu({ email, position, @@ -127,12 +114,13 @@ export function EmailContextMenu({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onMarkAsRead, onToggleStar, onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMoveToMailbox, onMarkAsSpam, onUndoSpam, @@ -149,13 +137,12 @@ export function EmailContextMenu({ }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); - const _tColor = useTranslations("email_viewer.color_tag"); - const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const tEmailViewer = useTranslations("email_viewer"); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; - const currentColors = getCurrentColors(email.keywords); + const currentTagIds = getEmailTagIds(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action @@ -164,13 +151,6 @@ export function EmailContextMenu({ const isScheduled = email.isScheduled === true; const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; - // Build color options from keyword definitions in settings - const colorOptions = emailKeywords.map((kw) => ({ - name: kw.label, - value: kw.id, - color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500", - })); - // Build mailbox tree for move-to submenu with proper hierarchy const moveTargetIds = new Set( mailboxes @@ -277,6 +257,12 @@ export function EmailContextMenu({ onClick={() => handleAction(onForward!)} disabled={!onForward} /> + handleAction(onForwardAsAttachment!)} + disabled={!onForwardAsAttachment || !email.blobId} + /> )} @@ -370,37 +356,13 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - - {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> - - handleAction(() => onSetColorTag?.(null))} - /> - - )} + +
+ onSetTag?.(tagId)} + /> +
)} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index a2691da7..2435d1c1 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -15,7 +15,7 @@ interface EmailHoverActionsProps { onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; // When the email lives in a junk folder (incl. the aggregate "All Junk" view) // the spam quick-action flips to "not spam". @@ -76,7 +76,7 @@ export function EmailHoverActions({ onMarkAsRead, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, isInJunk = false, onUndoSpam, @@ -112,7 +112,7 @@ export function EmailHoverActions({ onArchive?.(); break; case "tag": - onSetColorTag?.(null); + onSetTag?.(null); break; case "spam": if (isInJunk) onUndoSpam?.(); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx deleted file mode 100644 index 44c7e915..00000000 --- a/components/email/email-list-item.tsx +++ /dev/null @@ -1,351 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { useCallback } from "react"; -import { formatDate, stripInvisibleLeading } from "@/lib/utils"; -import { Email } from "@/lib/jmap/types"; -import { cn } from "@/lib/utils"; -import { SelectableAvatar } from "@/components/email/selectable-avatar"; -import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; -import { useEmailStore } from "@/stores/email-store"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { useEmailDrag } from "@/hooks/use-email-drag"; -import { useLongPress } from "@/hooks/use-long-press"; -import { useUIStore } from "@/stores/ui-store"; -import { EmailIdentityBadge } from "./email-identity-badge"; -import { EmailHoverActions } from "./email-hover-actions"; -import { getEmailColorTags } from "@/lib/thread-utils"; - -interface EmailListItemProps { - email: Email; - selected?: boolean; - onClick?: () => void; - onDoubleClick?: () => void; - onContextMenu?: (e: React.MouseEvent, email: Email) => void; - onToggleStar?: () => void; - onMarkAsRead?: (read: boolean) => void; - onDelete?: () => void; - onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; - onMarkAsSpam?: () => void; - onUndoSpam?: () => void; -} - -export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) { - const t = useTranslations('email_viewer'); - const tBatch = useTranslations('email_list.batch_actions'); - const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); - const showPreview = useSettingsStore((state) => state.showPreview); - const density = useSettingsStore((state) => state.density); - const mailLayout = useSettingsStore((state) => state.mailLayout); - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); - const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); - const { identities } = useAuthStore(); - const isChecked = selectedEmailIds.has(email.id); - const isUnread = !email.keywords?.$seen; - const isStarred = email.keywords?.$flagged; - const isPinned = email.keywords?.['$pinned'] === true; - const isImportant = email.keywords?.["$important"]; - const isAnswered = email.keywords?.$answered; - const isForwarded = email.keywords?.$forwarded; - // In Sent/Drafts folders, show recipient instead of sender (which is always "me"). - // In aggregate role-views the selected mailbox is virtual → fall back to the - // unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work. - const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role - ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); - const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; - const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; - const isMobile = useUIStore((state) => state.isMobile); - // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. - const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; - const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; - const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); - const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; - - // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray - const colorTagIds = getEmailColorTags(email.keywords); - const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); - // Use first tag for background coloring - const keywordDef = keywordDefs[0] ?? null; - const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; - - // Drag and drop functionality - const { dragHandlers, isDragging } = useEmailDrag({ - email, - sourceMailboxId: selectedMailbox, - }); - - const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( - useCallback((pos) => { - onContextMenu?.( - { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, - email - ); - }, [onContextMenu, email]), - isMobile - ); - const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }; - - const handleCheckboxClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (e.shiftKey) { - // Shift-click extends the selection from the anchor to here, like - // shift-clicking the row (the checkbox stops propagation, so the - // row's shift handler never runs — replicate it here). - selectRangeEmails(email.id); - } else { - toggleEmailSelection(email.id); - } - }; - - const handleContextMenu = (e: React.MouseEvent) => { - onContextMenu?.(e, email); - }; - - return ( -
{ - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - toggleEmailSelection(email.id); - } else if (e.shiftKey) { - e.preventDefault(); - selectRangeEmails(email.id); - } else { - if (selectedEmailIds.size > 0) clearSelection(); - onClick?.(); - } - }} - onDoubleClick={(e) => { - if (e.ctrlKey || e.metaKey || e.shiftKey) return; - if (!onDoubleClick) return; - e.preventDefault(); - onDoubleClick(); - }} - onContextMenu={handleContextMenu} - style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} - > -
- {/* Checkbox - only visible when in selection mode */} - {selectedEmailIds.size > 0 && ( - - )} - - {/* Unread indicator */} - {isUnread && ( -
- -
- )} - - {/* Avatar */} - {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} - selectLabel={tBatch('select')} - /> - )} - - {/* Content */} -
- {isFocusedMailLayout ? ( -
-
- - {sender?.name || sender?.email || 'Unknown'} - -
- - {email.subject || t('no_subject')} - - {inlinePreview && ( - {inlinePreview} - )} -
-
-
- {isPinned && } - {isStarred && } - {isImportant && } - {isAnswered && !isForwarded && } - {isForwarded && !isAnswered && } - {isAnswered && isForwarded && ( - <> - - - - )} - {email.hasAttachment && } - {keywordDefs.map((kd) => ( - - ))} - - {formatDate(email.receivedAt)} - -
-
- ) : ( - <> - {/* First Line: Sender and Date */} -
-
- - {sender?.name || sender?.email || "Unknown"} - -
- {isPinned && ( - - )} - {isStarred && ( - - )} - {isImportant && ( - - Important - - )} - - {isAnswered && !isForwarded && ( - - )} - {isForwarded && !isAnswered && ( - - )} - {isAnswered && isForwarded && ( - <> - - - - )} - {email.hasAttachment && ( - - )} -
-
-
- {keywordDefs.map((kd) => ( - - - {kd.label} - - ))} - - {formatDate(email.receivedAt)} - -
-
- - {/* Second Line: Subject */} -
- {email.subject || t('no_subject')} -
- - {/* Third Line: Preview (controlled by showPreview setting) */} - {showPreview && density !== 'extra-compact' && density !== 'compact' && ( -

- {trimmedPreview || t('no_preview_available')} -

- )} - - )} -
-
- - {/* Hover Quick Actions */} - -
- ); -} \ No newline at end of file diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 8f1db908..9251f78c 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; @@ -33,12 +34,13 @@ interface EmailListProps { onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; + onForwardAsAttachment?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; onTogglePinned?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMoveToMailbox?: (emailId: string, mailboxId: string) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; @@ -63,12 +65,13 @@ export function EmailList({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onMarkAsRead, onToggleStar, onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, onUndoSpam, onMoveToMailbox, @@ -130,10 +133,20 @@ export function EmailList({ }, [emails, disableThreading, isScheduledView, threadEmailCounts]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + /** + * The row the menu was opened on, as the list currently has it. The menu holds + * the message it was handed when it opened, but tags can be applied from + * inside it without dismissing it, so what it draws has to keep up. + */ + const contextMenuEmail = contextMenu.data + ? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data + : null; const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [isProcessing, setIsProcessing] = useState(false); const parentRef = useRef(null); + // One tag treatment for the whole list, measured from the scroll container. + const tagDisplay = useMeasuredTagDisplay(parentRef); const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -330,6 +343,7 @@ export function EmailList({ }, [density, isFocusedMailLayout, showPreview]); return ( +
{/* Batch Actions Toolbar */}
onMarkAsRead(email, read) : undefined} onDelete={onDelete ? (email) => onDelete(email) : undefined} onArchive={onArchive ? (email) => onArchive(email) : undefined} - onSetColorTag={onSetColorTag} + onSetTag={onSetTag} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined} /> @@ -568,9 +582,9 @@ export function EmailList({
{/* Context Menu */} - {contextMenu.data && ( + {contextMenuEmail && ( onReply?.(contextMenu.data!)} - onReplyAll={() => onReplyAll?.(contextMenu.data!)} - onForward={() => onForward?.(contextMenu.data!)} - onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} - onToggleStar={() => onToggleStar?.(contextMenu.data!)} - onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} - onDelete={() => onDelete?.(contextMenu.data!)} - onArchive={() => onArchive?.(contextMenu.data!)} - onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} - onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} - onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} - onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} - onEditDraft={() => onEditDraft?.(contextMenu.data!)} - onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined} - onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined} - onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined} + onReply={() => onReply?.(contextMenuEmail!)} + onReplyAll={() => onReplyAll?.(contextMenuEmail!)} + onForward={() => onForward?.(contextMenuEmail!)} + onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)} + onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)} + onToggleStar={() => onToggleStar?.(contextMenuEmail!)} + onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined} + onDelete={() => onDelete?.(contextMenuEmail!)} + onArchive={() => onArchive?.(contextMenuEmail!)} + onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)} + onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)} + onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)} + onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)} + onEditDraft={() => onEditDraft?.(contextMenuEmail!)} + onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined} + onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined} + onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { @@ -642,5 +657,6 @@ export function EmailList({
+
); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dd59209e..bbdc0a76 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -12,6 +12,11 @@ import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; +import { TagBadge } from "./tag-badge"; +import { TagPicker } from "./tag-picker"; +import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { getEmailTagIds } from "@/lib/thread-utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; @@ -19,6 +24,7 @@ import { Reply, ReplyAll, Forward, + Paperclip, Trash2, Archive, Star, @@ -73,7 +79,7 @@ import { import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; @@ -109,11 +115,12 @@ interface EmailViewerProps { onReply?: (draftText?: string) => void; onReplyAll?: () => void; onForward?: () => void; + onForwardAsAttachment?: () => void; onDelete?: () => void; onArchive?: () => void; onToggleStar?: () => void; onMarkAsRead?: (emailId: string, read: boolean) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void; onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; @@ -198,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st return 'Attachment'; }; -const getCurrentColors = (keywords: Record | undefined): string[] => { - if (!keywords) return []; - const tags: string[] = []; - for (const key of Object.keys(keywords)) { - if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - tags.push( - key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) - ); - } - } - return tags; -}; - // Helper function to format recipients with contextual display const _formatRecipients = ( recipients: Array<{ name?: string; email: string }> | undefined, @@ -621,11 +615,12 @@ export function EmailViewer({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onDelete, onArchive, onToggleStar, onMarkAsRead, - onSetColorTag, + onSetTag, onDownloadAttachment, onQuickReply, onMarkAsSpam, @@ -664,6 +659,7 @@ export function EmailViewer({ const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { sortTagIds, tagColor } = useKeywordFormat(); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -706,12 +702,6 @@ export function EmailViewer({ const isScheduled = email?.isScheduled === true; const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; - // Color options for email tags (from user-defined keyword settings) - const colorOptions = emailKeywords.map((kw) => ({ - name: kw.label, - value: kw.id, - color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', - })); // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); @@ -816,8 +806,13 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentColors = getCurrentColors(email?.keywords); - const currentColor = currentColors[0] ?? null; + const currentTagIds = getEmailTagIds(email?.keywords); + const sortedTagIds = sortTagIds(currentTagIds); + // The header spans the reading pane, so it measures its own width rather than + // inheriting the message list's answer. + const headerTagsRef = useRef(null); + const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef); + const currentColor = currentTagIds[0] ?? null; // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic // onRenderEmailBody hook. Verification/decryption status UI is provided by the @@ -1015,7 +1010,7 @@ export function EmailViewer({ showToolbarLabels, isLoading, moveTree.length, - colorOptions.length, + emailKeywords.length, currentColor, isInJunkFolder, isTablet, @@ -2994,64 +2989,18 @@ export function EmailViewer({
{tagMenuOpen && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); }} + />
)}
@@ -3243,7 +3192,7 @@ export function EmailViewer({
)} {/* Overflow: tag - submenu */} - {colorOptions.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} @@ -3257,36 +3206,11 @@ export function EmailViewer({ {moreMenuSub === 'tag' && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); }} + />
)}
@@ -3340,6 +3264,16 @@ export function EmailViewer({ )}
+ {/* Forward as attachment */} + {onForwardAsAttachment && email?.blobId && ( + + )} {/* Export email */} {/* Tag (opens sub-view) */} - {colorOptions.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && ( )}
+ {onForwardAsAttachment && email?.blobId && ( + + )} - ); - })} - {currentColors.length > 0 && ( - - )} - + {moreMenuSub === 'tag' && ( + { if (email) onSetTag?.(email.id, tagId); }} + /> )}
@@ -3605,24 +3527,24 @@ export function EmailViewer({ )} /> )} - {/* Color tag dots */} - {currentColors.length > 0 && ( - - {currentColors.map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; - const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; - return ( - - ); - })} - - )} {isImportant && ( {t('important')} )}
+ {sortedTagIds.length > 0 && ( +
+ {sortedTagIds.map((tagId) => ( + onSetTag(email.id, tagId) : undefined} + /> + ))} +
+ )}
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index c0e54519..446865b6 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -21,6 +21,7 @@ import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-ht import { SignatureBlock } from "@/components/email/signature-block"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; +import { useTranslations } from "next-intl"; import { Bold, Italic, @@ -153,6 +154,7 @@ const TEXT_COLORS = [ ]; function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) { + const t = useTranslations("email_composer.toolbar"); const [hover, setHover] = useState<{ r: number; c: number } | null>(null); return (
@@ -180,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v })}
- {hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"} + {hover ? `${hover.r + 1} × ${hover.c + 1}` : t("pick_size")}
); @@ -343,6 +345,7 @@ export function RichTextEditor({ .run(); }, [editor]); + const tToolbar = useTranslations("email_composer.toolbar"); const [tableMenuOpen, setTableMenuOpen] = useState(false); const tableWrapperRef = useRef(null); const [colorMenuOpen, setColorMenuOpen] = useState(false); @@ -383,28 +386,28 @@ export function RichTextEditor({ editor.chain().focus().toggleBold().run()} - title="Bold" + title={tToolbar("bold")} > editor.chain().focus().toggleItalic().run()} - title="Italic" + title={tToolbar("italic")} > editor.chain().focus().toggleUnderline().run()} - title="Underline" + title={tToolbar("underline")} > editor.chain().focus().toggleStrike().run()} - title="Strikethrough" + title={tToolbar("strikethrough")} > @@ -412,7 +415,7 @@ export function RichTextEditor({ setColorMenuOpen((v) => !v)} - title="Text color" + title={tToolbar("text_color")} > {/* The icon itself previews the active colour - no layout shift. */} @@ -446,7 +449,7 @@ export function RichTextEditor({ setColorMenuOpen(false); }} > - Remove color + {tToolbar("remove_color")}
)} @@ -457,14 +460,14 @@ export function RichTextEditor({ editor.chain().focus().toggleHeading({ level: 1 }).run()} - title="Heading 1" + title={tToolbar("heading_1")} > editor.chain().focus().toggleHeading({ level: 2 }).run()} - title="Heading 2" + title={tToolbar("heading_2")} > @@ -474,28 +477,28 @@ export function RichTextEditor({ editor.chain().focus().toggleBulletList().run()} - title="Bullet List" + title={tToolbar("bullet_list")} > editor.chain().focus().toggleOrderedList().run()} - title="Ordered List" + title={tToolbar("ordered_list")} > editor.chain().focus().toggleBlockquote().run()} - title="Quote" + title={tToolbar("quote")} > editor.chain().focus().toggleCodeBlock().run()} - title="Code Block" + title={tToolbar("code_block")} > @@ -505,21 +508,21 @@ export function RichTextEditor({ editor.chain().focus().setTextAlign("left").run()} - title="Align Left" + title={tToolbar("align_left")} > editor.chain().focus().setTextAlign("center").run()} - title="Align Center" + title={tToolbar("align_center")} > editor.chain().focus().setTextAlign("right").run()} - title="Align Right" + title={tToolbar("align_right")} > @@ -534,7 +537,7 @@ export function RichTextEditor({ editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir; editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run(); }} - title="Text direction (RTL/LTR)" + title={tToolbar("text_direction")} > @@ -545,7 +548,7 @@ export function RichTextEditor({ @@ -554,7 +557,7 @@ export function RichTextEditor({ setTableMenuOpen((v) => !v)} - title="Table" + title={tToolbar("table")} > @@ -567,28 +570,28 @@ export function RichTextEditor({ className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start" onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }} > - Add row above + {tToolbar("add_row_above")}
) : ( @@ -637,7 +640,7 @@ export function RichTextEditor({ editor.chain().focus().clearNodes().unsetAllMarks().run()} - title="Clear Formatting" + title={tToolbar("clear_formatting")} > @@ -647,14 +650,14 @@ export function RichTextEditor({ editor.chain().focus().undo().run()} disabled={!editor.can().undo()} - title="Undo" + title={tToolbar("undo")} > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} - title="Redo" + title={tToolbar("redo")} > diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx new file mode 100644 index 00000000..ca36f35f --- /dev/null +++ b/components/email/tag-badge.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useShortenedText } from "@/hooks/use-shortened-text"; + +/** + * How much room the surface has for a tag. + * - `badge` names the tag; `dot` only identifies it by colour. + */ +export type TagBadgeVariant = "badge" | "dot"; + +/** + * The lozenge shape, shared so anything standing next to a tag lines up with + * it rather than approximating its padding and text size. + */ +export const TAG_LOZENGE_CLASS = + "inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium"; + +/** + * The row a group of tags sits in. Using it for neighbouring lozenges too keeps + * the spacing between them the same as the spacing within them - a wider gap on + * one side is what makes a neighbour look indented. + */ +export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1"; + +/** + * A tag, drawn the one way tags are drawn. + * + * The lozenge carries the colour in its border and text rather than pairing a + * swatch with plain text: the name is the tag, and the colour is how you pick + * it out of a row at a glance. That also matches every other coloured pill in + * the app, all of which set a text colour alongside the background. + * + * A deep name shortens to fit its own box (`Work/../Acme`) before the browser + * clips it, so the outermost and innermost levels survive. + */ +export function TagBadge({ + tagId, + variant, + onRemove, + className, +}: { + tagId: string; + variant: TagBadgeVariant; + /** + * Takes the tag off the message. Only the named form offers it - a dot is the + * size of the control it would have to hold. + */ + onRemove?: () => void; + className?: string; +}) { + const t = useTranslations("email_viewer"); + const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(); + const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId)); + const color = tagColor(tagId); + const name = tagName(tagId); + + if (variant === "dot") { + return ( + + ); + } + + return ( + + + {shortenedName} + + {onRemove && ( + + )} + + ); +} diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx new file mode 100644 index 00000000..0f0948ef --- /dev/null +++ b/components/email/tag-picker.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Check, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settings-store"; +import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; + +/** Below this many tags a filter box costs more room than it saves. */ +const SEARCH_THRESHOLD = 10; + +/** + * The list of tags to apply to a message. + * + * Shared by all four places one appears - the toolbar popover, the overflow + * flyout, the mobile sheet and the context menu - because they had drifted into + * four different dot sizes, check alignments and separators, and only one of + * them capped its height. + * + * Nested tags are drawn as a tree rather than repeating the parent's name on + * every child. Filtering flattens it: with a query the hierarchy is noise, and + * the full path is what gets matched. + */ +export function TagPicker({ + selectedIds, + onToggle, + touch = false, +}: { + selectedIds: string[]; + onToggle: (tagId: string) => void; + /** Larger hit areas for the mobile sheet. */ + touch?: boolean; +}) { + const t = useTranslations("email_viewer"); + const keywords = useSettingsStore((state) => state.emailKeywords); + const nestedTags = useSettingsStore((state) => state.nestedTags); + const { tagName, tagColor } = useKeywordFormat(); + const [query, setQuery] = useState(""); + + const trimmedQuery = query.trim().toLowerCase(); + + /** + * Tags on the message this client has no definition for - set from another + * client, or outliving the tag they were made with. Listing them is the only + * way to take one off, and they leave the list as they are deselected because + * nothing but the message itself records that they exist. + */ + const unknownIds = useMemo( + () => + selectedIds + .filter((id) => !keywords.some((keyword) => keyword.id === id)) + .sort((a, b) => tagName(a).localeCompare(tagName(b))), + // `tagName` is rebuilt whenever the definitions or the nesting setting change. + [selectedIds, keywords, tagName], + ); + + const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD; + + const matches = useMemo( + () => + trimmedQuery + ? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) => + tagName(id).toLowerCase().includes(trimmedQuery), + ) + : [], + [keywords, unknownIds, trimmedQuery, tagName], + ); + + const tree = useMemo( + () => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))), + [keywords, nestedTags], + ); + + const rowClass = cn( + "w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer", + touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm", + ); + const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3"; + const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5"; + + const renderRow = (id: string, label: string) => { + const isActive = selectedIds.includes(id); + return ( + + ); + }; + + const renderBranch = (nodes: KeywordNode[]) => + nodes.map((node) => ( +
+ {renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)} + {node.children.length > 0 &&
{renderBranch(node.children)}
} +
+ )); + + return ( + <> + {showSearch && ( +
+ + setQuery(event.target.value)} + placeholder={t("tag_filter_placeholder")} + aria-label={t("tag_filter_placeholder")} + className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ )} + +
+ {trimmedQuery ? ( + matches.length > 0 ? ( + matches.map((id) => renderRow(id, tagName(id))) + ) : ( +

{t("tag_no_matches")}

+ ) + ) : ( + <> + {renderBranch(tree)} + {unknownIds.length > 0 && ( + <> + {keywords.length > 0 &&
} + {unknownIds.map((id) => renderRow(id, tagName(id)))} + + )} + + )} +
+ + ); +} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index bd35f4c5..a2859e66 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge } from "./tag-badge"; interface ThreadEmailItemProps { email: Email; @@ -35,6 +39,11 @@ export function ThreadEmailItem({ const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; + const { sortTagIds } = useKeywordFormat(); + const { variant: tagVariant } = useTagDisplay(); + // A message inside an expanded thread carries its own tags; the collapsed + // header pools them, so without this they disappear on the way in. + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); @@ -178,6 +187,9 @@ export function ThreadEmailItem({ {email.hasAttachment && ( )} + {tagIds.map((id) => ( + + ))}
{/* Preview snippet */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 51503972..b5473341 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; -import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; +import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -34,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) { ); } +/** + * How many messages a collapsed thread stands for. + * + * Built from the tag lozenge so it lines up with the tags it sits next to: the + * same shape, and the same group spacing. + */ +function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) { + return ( + + + {count} + + ); +} + interface ThreadListItemProps { thread: ThreadGroup; isExpanded: boolean; @@ -50,7 +75,7 @@ interface ThreadListItemProps { onMarkAsRead?: (email: Email, read: boolean) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; } @@ -62,18 +87,18 @@ interface SingleEmailItemProps { onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; - colorTag: string | null; + rowTint: string | null; onToggleStar?: () => void; onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; @@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef( ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; - const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef( ? formatDateTime(email.scheduledSendAt, timeFormat) : null; - // Resolve color tags using keyword definitions; unknown tags fall back to gray - const tagIds = getEmailColorTags(email.keywords); - const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); - const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; - const resolvedColorTag = !tintListRowsByTag ? null : (() => { - if (colorTag) return colorTag; - return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; - })(); + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); + const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null)); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef( data-unread={isUnread ? 'true' : 'false'} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", - resolvedColorTag ? resolvedColorTag : ( + resolvedRowTint ? resolvedRowTint : ( selected ? "bg-accent" : "bg-background" ), - selected && !resolvedColorTag && "shadow-sm", - !resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", - !resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", - resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !resolvedColorTag && "bg-accent/30", - isChecked && "ring-2 ring-primary/20 bg-accent/40", + selected && !resolvedRowTint && "shadow-sm", + !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", + !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", + resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", + isUnread && !resolvedRowTint && "bg-accent/30", + isChecked && "ring-2 ring-primary/20", + isChecked && !resolvedRowTint && "bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", - isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" + isPressed && "scale-[0.98] ring-2 ring-primary/30", + isPressed && !resolvedRowTint && "bg-muted" )} onClick={handleClick} onDoubleClick={(e) => { @@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || 'Unknown'}
+ {tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} ( )} {email.hasAttachment && } - {resolvedKeywordDefs.map((kd) => ( - - ))} {showSourceFolder && } {scheduledSendLabel ? ( ( )}> {sender?.name || sender?.email || "Unknown"} + {tagPlacement === 'sender' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )}
{isPinned && ( @@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef(
- {resolvedKeywordDefs.map((kd) => ( - - - {kd.label} - - ))} {showSourceFolder && } {scheduledSendLabel ? ( (
-
- {email.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {email.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef( {!email.isScheduled && ( state.emailKeywords); + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); - const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; - const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + // A collapsed row speaks for every message under it, so it carries their tags too. + const tagIds = sortTagIds(getThreadTagIds(thread.emails)); + const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef onEmailDoubleClick(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} - colorTag={colorTag} + rowTint={rowTint} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} /> @@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef { @@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef )} {displayNames.join(', ')} - - - {emailCount} -
+ + + {tagIds.map((id) => ( + + ))} + )} {hasAttachment && } - {keywordDef && ( - - )} {showSourceFolder && } {scheduledSendLabel ? ( {displayNames.join(", ")} - - - {emailCount} + + + {tagPlacement === 'sender' && tagIds.map((id) => ( + + ))}
{hasPinned && ( @@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef
- {keywordDef && ( - - - {keywordDef.label} - - )} {showSourceFolder && } {scheduledSendLabel ? (
-
- {latestEmail.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {latestEmail.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 221a6e7a..cca7a279 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -18,6 +18,7 @@ import type { import type { Mailbox } from "@/lib/jmap/types"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; interface FilterRuleModalProps { rule?: FilterRule; @@ -89,6 +90,7 @@ export function FilterRuleModal({ const t = useTranslations("settings.filters"); const isEdit = !!rule; const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName } = useKeywordFormat(); const [name, setName] = useState(rule?.name || ""); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); @@ -477,7 +479,7 @@ export function FilterRuleModal({ > {emailKeywords.map((kw) => ( - + ))} )} diff --git a/components/impersonation/impersonation-reconciler.tsx b/components/impersonation/impersonation-reconciler.tsx new file mode 100644 index 00000000..cd34673b --- /dev/null +++ b/components/impersonation/impersonation-reconciler.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useEffect } from 'react'; + +import { evictAll } from '@/lib/account-state-manager'; + +/** + * After a master-user impersonation handoff (`GET /api/auth/impersonate`), the + * server swaps the slot-0 session cookie but the client's *persisted* account + * registry still lists the PREVIOUS account — so the top-left account chip keeps + * showing the old mailbox even though the message list is correctly the new one. + * Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes + * it, because that state lives in localStorage and the impersonation redirect + * never reconciles it. (Reported downstream: jabali-panel #646.) + * + * The impersonate route now redirects to `/?impersonated=1`. Here we drop the + * stale persisted account + auth state (and the server-derived caches) and + * reload to a clean URL, so the app rehydrates empty and re-derives the single + * account from the fresh session cookie — the same result as the manual + * sign-out-then-reopen, done automatically. Cookies are untouched, so the + * just-granted impersonation session survives the reload. + */ +const STALE_KEYS = [ + 'account-registry', + 'auth-storage', + 'identity-storage', + 'contact-storage', + 'calendar-storage', + 'calendar-notification-storage', +]; + +export function ImpersonationReconciler() { + useEffect(() => { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + if (params.get('impersonated') !== '1') return; + + try { + evictAll(); + } catch { + /* in-memory snapshots are best-effort */ + } + for (const key of STALE_KEYS) { + try { + window.localStorage.removeItem(key); + } catch { + /* ignore storage access errors */ + } + } + + // Reload to a clean URL (drop the marker) so the now-empty persisted stores + // rehydrate and the app reconnects + re-derives the impersonated account + // from the session cookie. The marker is gone on the second load, so this + // runs exactly once. + params.delete('impersonated'); + const query = params.toString(); + window.location.replace(window.location.pathname + (query ? `?${query}` : '')); + }, []); + + return null; +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index bc61bcc6..e85b42b0 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -35,9 +35,19 @@ import { BellOff, Mails, MailOpen, + MoreHorizontal, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { + buildKeywordTree, + countKeywordNodes, + filterKeywordTree, + hasChildKeywords, + type KeywordNode, +} from "@/lib/keyword-nesting"; +import { useShortenedText } from "@/hooks/use-shortened-text"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { isEditableEventTarget } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -51,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; -import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store"; +import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -241,6 +251,9 @@ function SidebarRowCounts({ interface SidebarRowProps { icon: ReactNode; label: string; + /** Progressively shorter renderings of `label`, longest first. The widest one + * that fits the row is shown; without this the full label is used. */ + labelCandidates?: string[]; depth?: number; isSelected?: boolean; isVirtual?: boolean; @@ -266,6 +279,7 @@ interface SidebarRowProps { function SidebarRow({ icon, label, + labelCandidates, depth = 0, isSelected = false, isVirtual = false, @@ -288,6 +302,7 @@ function SidebarRow({ }: SidebarRowProps) { const t = useTranslations('sidebar'); const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; + const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]); return (
{!isCollapsed && ( <> - {label} + {shortenedLabel} = { - red: "text-red-600/75 dark:text-red-400/75", - orange: "text-orange-600/75 dark:text-orange-400/75", - yellow: "text-yellow-600/75 dark:text-yellow-400/75", - green: "text-green-600/75 dark:text-green-400/75", - blue: "text-blue-600/75 dark:text-blue-400/75", - purple: "text-purple-600/75 dark:text-purple-400/75", - pink: "text-pink-600/75 dark:text-pink-400/75", - teal: "text-teal-600/75 dark:text-teal-400/75", - cyan: "text-cyan-600/75 dark:text-cyan-400/75", - indigo: "text-indigo-600/75 dark:text-indigo-400/75", - amber: "text-amber-600/75 dark:text-amber-400/75", - lime: "text-lime-600/75 dark:text-lime-400/75", - gray: "text-gray-500", -}; +function ShowAllTagsRow({ + hiddenCount, + showAll, + onToggle, + isCollapsed, +}: { + hiddenCount: number; + showAll: boolean; + onToggle: () => void; + isCollapsed: boolean; +}) { + const t = useTranslations('sidebar'); + + return ( + } + label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })} + depth={0} + onClick={onToggle} + isCollapsed={isCollapsed} + /> + ); +} function TagItem({ - kw, - isSelected, + node, + selectedKeyword, + expandedTags, isCollapsed, onTagSelect, - totalCount, - unreadCount, + onToggleExpand, + tagCounts, colorful, }: { - kw: KeywordDefinition; - isSelected: boolean; + node: KeywordNode; + selectedKeyword: string | null; + expandedTags: Set; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; - totalCount: number; - unreadCount: number; + onToggleExpand: (keywordId: string) => void; + tagCounts: Record; colorful: boolean; }) { const t = useTranslations('notifications'); - const palette = KEYWORD_PALETTE[kw.color]; + const { tagNameCandidates, tagColor } = useKeywordFormat(); + const palette = tagColor(node.id); + const hasChildren = node.children.length > 0; + const isExpanded = expandedTags.has(node.id); + const isSelected = selectedKeyword === node.id; + // Nested rows are placed by their indentation, so they show their own name. + // A root spells out its path, which matters when an intermediate tag is + // missing from this client's settings and the row would otherwise read as a + // bare leaf name. + const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label]; + const label = labelCandidates[0]; + // Toasts have the room for the whole thing, and no indentation to lean on, + // so they always spell out the full path - otherwise two leaves with the + // same name in different branches (e.g. "Personal/Receipts" and + // "Work/Receipts") would read as the same tag. + const fullLabel = tagNameCandidates(node.id)[0]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ - tagId: kw.id, - onSuccess: (count, _tagLabel) => { + tagId: node.id, + onSuccess: (count) => { if (count === 1) { - toast.success(t('email_tagged'), kw.label); + toast.success(t('email_tagged'), fullLabel); } else { - toast.success(t('emails_tagged', { count }), kw.label); + toast.success(t('emails_tagged', { count }), fullLabel); } }, onError: () => { - toast.error(t('tag_failed'), kw.label); + toast.error(t('tag_failed'), fullLabel); }, }); const tagIcon = colorful ? ( - + ) : ( - + ); return ( - onTagSelect?.(isSelected ? null : kw.id)} - isCollapsed={isCollapsed} - dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} - isValidDropTarget={isValidDropTarget} - /> + <> + onTagSelect?.(isSelected ? null : node.id)} + hasChildren={hasChildren} + isExpanded={isExpanded} + onExpandToggle={() => onToggleExpand(node.id)} + isCollapsed={isCollapsed} + dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} + isValidDropTarget={isValidDropTarget} + /> + + {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( + + ))} + ); } @@ -737,6 +794,8 @@ export function Sidebar({ const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [expandedTags, setExpandedTags] = useState>(new Set()); + const [showAllTags, setShowAllTags] = useState(false); const [foldersExpanded, setFoldersExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarFoldersExpanded'); @@ -779,6 +838,7 @@ export function Sidebar({ return new Set(); }); const emailKeywords = useSettingsStore(s => s.emailKeywords); + const nestedTags = useSettingsStore(s => s.nestedTags); const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's // own AccountSwitcher would be a redundant second account UI in the same @@ -842,6 +902,37 @@ export function Sidebar({ }); }; + useEffect(() => { + const stored = localStorage.getItem('expandedTags'); + if (stored) { + try { + const parsed = JSON.parse(stored); + setExpandedTags(new Set(parsed)); + } catch (e) { + debug.error('Failed to parse expanded tags:', e); + } + } else { + setExpandedTags( + new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id)) + ); + } + }, [emailKeywords]); + + const handleToggleTagExpand = (keywordId: string) => { + setExpandedTags((prev) => { + const next = new Set(prev); + if (next.has(keywordId)) { + next.delete(keywordId); + } else { + next.add(keywordId); + } + try { + localStorage.setItem('expandedTags', JSON.stringify(Array.from(next))); + } catch { /* storage full or unavailable */ } + return next; + }); + }; + // When the app renders its own virtual "Scheduled" folder (for delayed // sends, driven by EmailSubmission), hide the server-provided scheduled // mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled') @@ -852,6 +943,29 @@ export function Sidebar({ const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n)); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); + // With nesting off every tag is its own root, so the same rows render through + // one path whether or not the ids describe a hierarchy. + const tagTree: KeywordNode[] = nestedTags + ? buildKeywordTree(emailKeywords) + : emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 })); + + // Counts arrive from a separate JMAP round trip, one batch per group of tags; + // a tag with no count yet is treated as visible rather than blanking it and + // filling it back in. A tag the server answered for with zero unread hides, + // which is the point of the setting. + const isTagVisible = (node: KeywordNode) => { + if (showAllTags || node.id === selectedKeyword) return true; + const visibility = getKeywordVisibility(node); + if (visibility === 'hide') return false; + if (visibility === 'unread') { + const count = tagCounts[node.id]; + return !count || count.unread > 0; + } + return true; + }; + const visibleTagTree = filterKeywordTree(tagTree, isTagVisible); + const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree); + // Multi-account mode (Pro shell): render every connected account as its // own collapsible group. The active account's tree comes from the // `mailboxes` prop (which is the live email-store value); other accounts @@ -1265,18 +1379,27 @@ export function Sidebar({ /> {((tagsExpanded && !isCollapsed) || isCollapsed) && ( <> - {emailKeywords.map((kw) => ( + {visibleTagTree.map((node) => ( ))} + {(hiddenTagCount > 0 || showAllTags) && ( + setShowAllTags((prev) => !prev)} + isCollapsed={isCollapsed} + /> + )} )}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 838aaa74..b37a5abe 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -15,6 +15,8 @@ import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/st import type { Email } from "@/lib/jmap/types"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { getQuoteBodies } from "@/lib/email-composer-utils"; +import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; interface ProEmailTabBodyProps { tabId: string; @@ -56,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const moveToMailbox = useEmailStore((s) => s.moveToMailbox); const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal); const mailboxes = useEmailStore((s) => s.mailboxes); - const settingsKeywords = useSettingsStore((s) => s.emailKeywords); const identities = useIdentityStore((s) => s.identities); const multiAccountIdentities = useProMultiAccountIdentities(); @@ -136,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { }); }, [email, openComposeTab, t]); + // Mirrors handleForward, but attaches the original as a message/rfc822 + // file instead of quoting it inline - see lib/forward-as-attachment.ts. + // This is a separate, self-contained render path from the main Mail + // tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and + // open compose tabs directly via useProTabStore, not through + // page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own + // wiring rather than falling out of the page.tsx fix automatically. + const handleForwardAsAttachment = useCallback(() => { + if (!email) return; + const { + emailDownloadTemplate, + filenameSpaceReplacement, + filenameLowercase, + filenameStripDiacritics, + filenameCollapseSeparators, + } = useSettingsStore.getState(); + const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), { + template: emailDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }); + if (!payload) return; + + composerSessionIdRef.current += 1; + openComposeTab({ + sessionId: composerSessionIdRef.current, + mode: 'forward', + replyTo: { + subject: email.subject, + attachments: [payload.attachment], + }, + sourceEmailId: email.id, + // payload.subject is intentionally blank for a subject-less email (to + // match normal Forward's *composer* subject behavior - see + // buildForwardAsAttachmentPayload). The Pro tab *title* is a separate + // UI label that still needs a sensible fallback, same as handleForward + // above uses - reusing payload.subject here would give the tab an + // empty title instead of e.g. "Fwd: New message". + title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')), + }); + }, [email, openComposeTab, t]); + const handleDelete = useCallback(async () => { if (!client || !email) return; try { @@ -191,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { } }, [client, markAsRead]); - const handleSetColorTag = useCallback((emailId: string, color: string | null) => { + const handleSetTag = useCallback((emailId: string, tagId: string | null) => { if (!email || email.id !== emailId) return; - // Drop existing color keywords, optionally add the new one. Matches the - // mail page's local optimistic update. + // Toggle one tag, or clear them all. Matches the mail page's local + // optimistic update, down to reaching tags this client cannot name. const keywords = { ...(email.keywords ?? {}) }; - for (const kw of settingsKeywords) { - delete keywords[`$label:${kw.id}`]; - } - if (color) { - const def = settingsKeywords.find((k) => k.color === color); - if (def) keywords[`$label:${def.id}`] = true; + if (tagId === null) { + for (const key of Object.keys(keywords)) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { + keywords[key] = false; + } + } + } else { + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + for (const key of activeKeys) { + keywords[key] = false; + } + } else { + keywords[KEYWORD_PREFIX + tagId] = true; + } } setEmailKeywordsLocal(emailId, keywords); setEmail({ ...email, keywords }); - }, [email, settingsKeywords, setEmailKeywordsLocal]); + }, [email, setEmailKeywordsLocal]); const handleMoveToMailbox = useCallback(async (mailboxId: string) => { if (!client || !email) return; @@ -283,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { onReply={handleReply} onReplyAll={handleReplyAll} onForward={handleForward} + onForwardAsAttachment={handleForwardAsAttachment} onDelete={handleDelete} onArchive={handleArchive} onToggleStar={handleToggleStar} onMarkAsRead={handleMarkAsRead} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onEditDraft={handleEditDraft} diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 1c3c5f94..c1664efa 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { KeywordSettings } from '../keyword-settings'; import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; -// Mock SettingsSection to just render children -vi.mock('../settings-section', () => ({ +// Mock SettingsSection to just render children, keeping the real controls +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, })); describe('KeywordSettings', () => { beforeEach(() => { - useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false }); }); it('renders all default keywords', () => { @@ -31,11 +32,6 @@ describe('KeywordSettings', () => { expect(screen.getByText('add_keyword')).toBeInTheDocument(); }); - it('renders reset defaults button', () => { - render(); - expect(screen.getByText('reset_defaults')).toBeInTheDocument(); - }); - it('shows add form when add button clicked', () => { render(); fireEvent.click(screen.getByText('add_keyword')); @@ -114,18 +110,6 @@ describe('KeywordSettings', () => { expect(kw?.label).toBe('Crimson'); }); - it('resets to defaults when reset button clicked', () => { - // Modify keywords first - useSettingsStore.getState().removeKeyword('red'); - useSettingsStore.getState().removeKeyword('blue'); - expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2); - - render(); - fireEvent.click(screen.getByText('reset_defaults')); - - expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS); - }); - it('normalizes label to id correctly', () => { render(); fireEvent.click(screen.getByText('add_keyword')); @@ -139,4 +123,90 @@ describe('KeywordSettings', () => { expect(added.id).toBe('my-custom-tag'); expect(added.label).toBe('My Custom Tag!'); }); + + it('offers no parent picker while nesting is off', () => { + render(); + fireEvent.click(screen.getByText('add_keyword')); + + expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument(); + }); + + it('nests a new tag under the selected parent', () => { + useSettingsStore.setState({ + emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }], + nestedTags: true, + }); + render(); + fireEvent.click(screen.getByText('add_keyword')); + + fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } }); + fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } }); + fireEvent.click(screen.getByText('add')); + + const keywords = useSettingsStore.getState().emailKeywords; + expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' }); + }); + + it('shows nested tags by their full path', () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + ], + nestedTags: true, + }); + render(); + + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + expect(screen.getByText('$label:work/clients')).toBeInTheDocument(); + }); + + it('rejects a path that would exceed the keyword length limit', () => { + const deepId = 'a'.repeat(240); + useSettingsStore.setState({ + emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }], + nestedTags: true, + }); + render(); + fireEvent.click(screen.getByText('add_keyword')); + + fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } }); + fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } }); + + expect(screen.getByText('too_long')).toBeInTheDocument(); + expect(screen.getByText('add').closest('button')).toBeDisabled(); + }); + + it('locks the name and the delete action of a tag that has nested tags', () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + ], + nestedTags: true, + }); + render(); + + expect(screen.getByTitle('has_children_delete')).toBeDisabled(); + + fireEvent.click(screen.getAllByTitle('edit')[0]); + expect(screen.getByDisplayValue('Work')).toBeDisabled(); + expect(screen.getByText('has_children_locked')).toBeInTheDocument(); + }); + + it('defaults every tag to always visible in the sidebar', () => { + render(); + + const pickers = screen.getAllByLabelText('visibility_field'); + expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length); + pickers.forEach((picker) => expect(picker).toHaveValue('show')); + }); + + it('stores the visibility chosen for a tag', () => { + render(); + + fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } }); + + expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread'); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index c4fcdb2d..51beee54 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -2,15 +2,35 @@ import React, { useState } from "react"; import { useTranslations } from "next-intl"; -import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; +import { + useSettingsStore, + KEYWORD_PALETTE, + KEYWORD_PALETTE_ROWS, + getKeywordVisibility, + type KeywordDefinition, + type KeywordVisibility, +} from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; -import { SettingsSection } from "./settings-section"; -import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react"; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section"; +import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { KEYWORD_PREFIX } from "@/lib/thread-utils"; +import { + buildKeywordTree, + composeKeywordId, + getParentKeywordId, + hasChildKeywords, + isKeywordDescendant, + keywordLevels, + type KeywordNode, + MAX_KEYWORD_ID_LENGTH, +} from "@/lib/keyword-nesting"; +import { formatKeyword, keywordRenderings } from "@/lib/keyword-format"; +import { useShortenedText } from "@/hooks/use-shortened-text"; +import { TagBadge } from "@/components/email/tag-badge"; -const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); - +/** Lighter, base and darker shade of each hue, one row per shade. */ function KeywordColorPicker({ value, onChange, @@ -19,19 +39,23 @@ function KeywordColorPicker({ onChange: (color: string) => void; }) { return ( -
- {PALETTE_KEYS.map((colorKey) => ( -
))}
); @@ -39,8 +63,11 @@ function KeywordColorPicker({ function KeywordRow({ keyword, + keywords, + nestedTags, onEdit, onDelete, + onVisibilityChange, onDragStart, onDragOver, onDrop, @@ -49,8 +76,11 @@ function KeywordRow({ isDragging, }: { keyword: KeywordDefinition; + keywords: KeywordDefinition[]; + nestedTags: boolean; onEdit: () => void; onDelete: () => void; + onVisibilityChange: (visibility: KeywordVisibility) => void; onDragStart: () => void; onDragOver: (e: React.DragEvent) => void; onDrop: () => void; @@ -59,7 +89,16 @@ function KeywordRow({ isDragging: boolean; }) { const t = useTranslations("settings.keywords"); - const palette = KEYWORD_PALETTE[keyword.color]; + const hasChildren = hasChildKeywords(keyword.id, keywords); + // Measured with the prefix attached, since that is what occupies the column. + const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) + .map((rendering) => KEYWORD_PREFIX + rendering); + const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates); + const visibilityOptions = [ + { value: "show", label: t("visibility.show") }, + { value: "unread", label: t("visibility.unread") }, + { value: "hide", label: t("visibility.hide") }, + ]; return (
-
- {keyword.label} - {"$label:" + keyword.id} +
+ +
+ + {shortenedKeyword} + + +
+ )}