Merge branch 'bulwarkmail:main' into user-api-plugin

This commit is contained in:
Paulhenry Saux
2026-08-01 20:24:08 +02:00
committed by GitHub
106 changed files with 10802 additions and 2479 deletions
+10
View File
@@ -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)
# =============================================================================
+162 -1
View File
@@ -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
+79 -33
View File
@@ -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__/<name>.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/<code>/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
+111 -107
View File
@@ -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
+77 -35
View File
@@ -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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
</picture>
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
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Light mode</b> full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr>
</table>
## 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.
</details>
<details>
<summary>Anonymous telemetry</summary>
```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.
</details>
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
</details>
<details>
<summary>Default UI locale</summary>
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 .
```
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
@@ -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.
</details>
## 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?
+2
View File
@@ -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({
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<PluginConsentDialog />
+140 -29
View File
@@ -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}
+15 -1
View File
@@ -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 });
}
}
+3 -2
View File
@@ -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' },
});
}
File diff suppressed because one or more lines are too long
+35
View File
@@ -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 {
@@ -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(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
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(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
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(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
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(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
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(<ContactForm contact={orgContact} onSave={vi.fn()} onCancel={vi.fn()} />);
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(<ContactForm contact={orgContact} onSave={onSave} onCancel={vi.fn()} />);
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');
});
});
+3 -1
View File
@@ -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));
+117 -44
View File
@@ -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<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
@@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const mediaValue: Record<string, ContactMedia> | 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<ContactCard> = {
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
)}
<FormSection icon={User} title={t("section_identity")}>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
{[
{ org: false, label: t("type_person"), icon: User },
{ org: true, label: t("type_organization"), icon: Building },
].map(({ org, label, icon: Icon }) => (
<button
key={label}
type="button"
role="radio"
aria-checked={isOrg === org}
onClick={() => setIsOrg(org)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
isOrg === org
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
{isOrg ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("organization")} <span className="text-red-500">*</span>
</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
) : (
<>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</>
)}
</FormSection>
{/* Email */}
@@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
{/* In organization mode the org name is the card's identity, edited above. */}
{!isOrg && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
)}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
@@ -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> = {}): 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<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show tag badge when email has no label keyword', () => {
const email = makeEmail({ keywords: { $seen: true } });
render(<EmailListItem email={email} />);
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(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows tag badge for legacy $color: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
render(<EmailListItem email={email} />);
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(<EmailListItem email={email} />);
// 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(<EmailListItem email={email} />);
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('updates badge when keyword definition changes', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
// Update label name
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
rerender(<EmailListItem email={email} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
it('renders subject even without tag', () => {
const email = makeEmail({ subject: 'Hello World' });
render(<EmailListItem email={email} />);
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(<EmailListItem email={email} />);
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(<EmailListItem email={e3} />);
// 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);
});
});
@@ -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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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(<EmailComposer mode="reply" replyTo={RECEIVED} />);
expect(toChips()).toEqual(['Bob (bob@other.com)']);
});
it('reply-all keeps the other recipients but not our own address', () => {
render(<EmailComposer mode="replyAll" replyTo={RECEIVED} />);
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(<EmailComposer mode="reply" replyTo={SELF_SENT} />);
expect(toChips()).toEqual(['Bob (bob@other.com)']);
});
it('reply-all on our own message restores the original To and Cc', () => {
render(<EmailComposer mode="replyAll" replyTo={SELF_SENT} />);
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(<EmailComposer mode="reply" replyTo={{ ...SELF_SENT, from: [{ email: 'info@example.com', name: 'Info' }] }} />);
expect(identitySelect().value).toBe('id-info');
});
});
@@ -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(<TagBadge tagId="work/clients" variant="badge" />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
});
it('names a tag it has no definition for by its id', () => {
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
});
it('offers removal only when asked to', () => {
const onRemove = vi.fn();
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
});
});
@@ -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(<TagPicker selectedIds={[]} onToggle={() => {}} />);
// 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(<TagPicker selectedIds={[]} onToggle={() => {}} />);
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(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
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(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
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(<TagPicker selectedIds={[]} onToggle={onToggle} />);
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(<TagPicker selectedIds={strays} onToggle={() => {}} />);
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(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
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(<TagPicker selectedIds={[]} onToggle={() => {}} />);
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(<TagPicker selectedIds={[]} onToggle={() => {}} />);
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(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(container.querySelectorAll('.ps-4').length).toBe(0);
expect(screen.getByText('Clients')).toBeInTheDocument();
});
});
@@ -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> = {}): 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(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
describe('ThreadListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
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(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
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<string>(),
selectedMailbox: 'inbox',
});
});
function renderThread(emails: Email[], expanded = false) {
const [thread] = groupEmailsByThread(emails);
return render(
<ThreadListItem
thread={thread}
isExpanded={expanded}
expandedEmails={expanded ? emails : undefined}
onToggleExpand={() => {}}
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<string>(),
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');
});
});
+21 -21
View File
@@ -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,
]);
+23 -61
View File
@@ -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<string, boolean> | 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}
/>
<ContextMenuItem
icon={Paperclip}
label={tEmailViewer("forward_as_attachment")}
onClick={() => handleAction(onForwardAsAttachment!)}
disabled={!onForwardAsAttachment || !email.blobId}
/>
<ContextMenuSeparator />
</>
)}
@@ -370,37 +356,13 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
</div>
</ContextMenuSubMenu>
)}
+3 -3
View File
@@ -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?.();
-351
View File
@@ -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 (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-selection"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-selection/60",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
// Long press visual feedback
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={(e) => {
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)' }}
>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{/* Unread indicator */}
{isUnread && (
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{/* Avatar */}
{density !== 'extra-compact' && (
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={() => toggleEmailSelection(email.id)}
selectLabel={tBatch('select')}
/>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
{inlinePreview && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
) : (
<>
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
)}
</div>
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
</div>
);
}
+38 -22
View File
@@ -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<Email>();
/**
* 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<HTMLDivElement>(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 (
<TagDisplayContext.Provider value={tagDisplay}>
<div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */}
<div
@@ -541,7 +555,7 @@ export function EmailList({
onMarkAsRead={onMarkAsRead ? (email, read) => 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({
</div>
{/* Context Menu */}
{contextMenu.data && (
{contextMenuEmail && (
<EmailContextMenu
email={contextMenu.data}
email={contextMenuEmail}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
@@ -578,24 +592,25 @@ export function EmailList({
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
selectedCount={selectedEmailIds.size}
onReply={() => 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({
<ConfirmDialog {...confirmDialogProps} />
</div>
</TagDisplayContext.Provider>
);
}
+80 -158
View File
@@ -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<void>;
onMarkAsSpam?: () => void;
@@ -198,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColors = (keywords: Record<string, boolean> | 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<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(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<HTMLDivElement>(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({
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
title={t('set_tag')}
>
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
</button>
{tagMenuOpen && (
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
@@ -3243,7 +3192,7 @@ export function EmailViewer({
</div>
)}
{/* Overflow: tag - submenu */}
{colorOptions.length > 0 && (
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)}
@@ -3257,36 +3206,11 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
@@ -3340,6 +3264,16 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{/* Forward as attachment */}
{onForwardAsAttachment && email?.blobId && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Paperclip className="w-4 h-4" />
{t('forward_as_attachment')}
</button>
)}
{/* Export email */}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
@@ -3420,19 +3354,21 @@ export function EmailViewer({
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
{currentTagIds.length > 0 && (
<div className="flex -space-x-1 me-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
})}
{sortedTagIds.slice(0, 3).map((tagId) => (
<span
key={tagId}
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
/>
))}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
@@ -3462,6 +3398,15 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{onForwardAsAttachment && email?.blobId && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Paperclip className="w-5 h-5" />
{t('forward_as_attachment')}
</button>
)}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
@@ -3519,35 +3464,12 @@ export function EmailViewer({
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
</>
{moreMenuSub === 'tag' && (
<TagPicker
touch
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
)}
</div>
</div>
@@ -3605,24 +3527,24 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{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 (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
);
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
</span>
)}
</div>
{sortedTagIds.length > 0 && (
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
{sortedTagIds.map((tagId) => (
<TagBadge
key={tagId}
tagId={tagId}
variant={headerTagVariant}
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
/>
))}
</div>
)}
</div>
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-end">
+33 -30
View File
@@ -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 (
<div>
@@ -180,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v
})}
</div>
<div className="text-xs text-muted-foreground mt-1.5 text-center">
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
{hover ? `${hover.r + 1} × ${hover.c + 1}` : t("pick_size")}
</div>
</div>
);
@@ -343,6 +345,7 @@ export function RichTextEditor({
.run();
}, [editor]);
const tToolbar = useTranslations("email_composer.toolbar");
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
@@ -383,28 +386,28 @@ export function RichTextEditor({
<ToolbarButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
title={tToolbar("bold")}
>
<Bold className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
title={tToolbar("italic")}
>
<Italic className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Underline"
title={tToolbar("underline")}
>
<UnderlineIcon className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
title={tToolbar("strikethrough")}
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
@@ -412,7 +415,7 @@ export function RichTextEditor({
<ToolbarButton
active={!!editor.getAttributes("textStyle").color}
onClick={() => setColorMenuOpen((v) => !v)}
title="Text color"
title={tToolbar("text_color")}
>
{/* The icon itself previews the active colour - no layout shift. */}
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
@@ -446,7 +449,7 @@ export function RichTextEditor({
setColorMenuOpen(false);
}}
>
<RemoveFormatting className="w-4 h-4" /> Remove color
<RemoveFormatting className="w-4 h-4" /> {tToolbar("remove_color")}
</button>
</div>
)}
@@ -457,14 +460,14 @@ export function RichTextEditor({
<ToolbarButton
active={editor.isActive("heading", { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
title="Heading 1"
title={tToolbar("heading_1")}
>
<Heading1 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
title="Heading 2"
title={tToolbar("heading_2")}
>
<Heading2 className="w-4 h-4" />
</ToolbarButton>
@@ -474,28 +477,28 @@ export function RichTextEditor({
<ToolbarButton
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Bullet List"
title={tToolbar("bullet_list")}
>
<List className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Ordered List"
title={tToolbar("ordered_list")}
>
<ListOrdered className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
title="Quote"
title={tToolbar("quote")}
>
<Quote className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
title="Code Block"
title={tToolbar("code_block")}
>
<Code className="w-4 h-4" />
</ToolbarButton>
@@ -505,21 +508,21 @@ export function RichTextEditor({
<ToolbarButton
active={editor.isActive({ textAlign: "left" })}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
title="Align Left"
title={tToolbar("align_left")}
>
<AlignLeft className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive({ textAlign: "center" })}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
title="Align Center"
title={tToolbar("align_center")}
>
<AlignCenter className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor.isActive({ textAlign: "right" })}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
title="Align Right"
title={tToolbar("align_right")}
>
<AlignRight className="w-4 h-4" />
</ToolbarButton>
@@ -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")}
>
<ArrowLeftRight className="w-4 h-4" />
</ToolbarButton>
@@ -545,7 +548,7 @@ export function RichTextEditor({
<ToolbarButton
active={editor.isActive("link")}
onClick={addLink}
title="Link"
title={tToolbar("link")}
>
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
@@ -554,7 +557,7 @@ export function RichTextEditor({
<ToolbarButton
active={editor.isActive("table")}
onClick={() => setTableMenuOpen((v) => !v)}
title="Table"
title={tToolbar("table")}
>
<TableIcon className="w-4 h-4" />
</ToolbarButton>
@@ -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); }}
>
<Rows3 className="w-4 h-4" /> Add row above
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_above")}
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row below
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_below")}
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column before
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_before")}
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column after
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_after")}
</button>
<div className="h-px bg-border my-1" />
<button
@@ -596,21 +599,21 @@ 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().deleteRow().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete row
<Trash2 className="w-4 h-4" /> {tToolbar("delete_row")}
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete column
<Trash2 className="w-4 h-4" /> {tToolbar("delete_column")}
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Toggle header row
<Rows3 className="w-4 h-4" /> {tToolbar("toggle_header_row")}
</button>
<div className="h-px bg-border my-1" />
<button
@@ -618,7 +621,7 @@ export function RichTextEditor({
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete table
<Trash2 className="w-4 h-4" /> {tToolbar("delete_table")}
</button>
</div>
) : (
@@ -637,7 +640,7 @@ export function RichTextEditor({
<ToolbarButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Clear Formatting"
title={tToolbar("clear_formatting")}
>
<RemoveFormatting className="w-4 h-4" />
</ToolbarButton>
@@ -647,14 +650,14 @@ export function RichTextEditor({
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo"
title={tToolbar("undo")}
>
<Undo className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo"
title={tToolbar("redo")}
>
<Redo className="w-4 h-4" />
</ToolbarButton>
+99
View File
@@ -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 (
<span
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
title={name}
aria-label={name}
/>
);
}
return (
<span
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[12rem] border",
color.fill,
color.border,
color.text,
className,
)}
title={name}
>
<span ref={labelRef} className="min-w-0 truncate">
{shortenedName}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
title={t("remove_tag")}
aria-label={t("remove_tag")}
>
<X className="w-3 h-3" />
</button>
)}
</span>
);
}
+147
View File
@@ -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 (
<button
key={id}
type="button"
role="menuitemcheckbox"
aria-checked={isActive}
onClick={() => onToggle(id)}
className={cn(rowClass, isActive && "bg-accent font-medium")}
title={tagName(id)}
>
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
<span className="flex-1 min-w-0 truncate">{label}</span>
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
</button>
);
};
const renderBranch = (nodes: KeywordNode[]) =>
nodes.map((node) => (
<div key={node.id}>
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
</div>
));
return (
<>
{showSearch && (
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(event) => 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"
/>
</div>
)}
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
{trimmedQuery ? (
matches.length > 0 ? (
matches.map((id) => renderRow(id, tagName(id)))
) : (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
)
) : (
<>
{renderBranch(tree)}
{unknownIds.length > 0 && (
<>
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
{unknownIds.map((id) => renderRow(id, tagName(id)))}
</>
)}
</>
)}
</div>
</>
);
}
+12
View File
@@ -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 && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</div>
{/* Preview snippet */}
+136 -102
View File
@@ -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 (
<span
className={cn(
TAG_LOZENGE_CLASS,
"gap-0.5",
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
title={title}
>
<MessageSquare className="w-3 h-3" />
{count}
</span>
);
}
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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
?? (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<HTMLDivElement, SingleEmailItemProps>(
? 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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
{tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -281,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
{tagPlacement === 'sender' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</span>
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{!email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
onUndoSpam,
}, ref) {
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => 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<HTMLDivElement, ThreadListItemPro
onDoubleClick={onEmailDoubleClick ? () => 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<HTMLDivElement, ThreadListItemPro
{...threadLongPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
colorTag ? colorTag : (
rowTint ? rowTint : (
isSelected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isSelected && !rowTint && "shadow-sm",
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
rowTint && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !rowTint && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
isChecked && "ring-2 ring-primary/20",
isChecked && !rowTint && "bg-accent/40",
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
isThreadPressed && !rowTint && "bg-muted"
)}
onClick={handleHeaderClick}
onDoubleClick={(e) => {
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
// Matches SingleEmailItem: the sender column sets where
// every row's tags and subject begin, so the two have to
// agree or thread rows sit 1rem further right.
'w-32 shrink-0 truncate text-sm lg:w-40',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{displayNames.join(', ')}
</span>
<span
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<span className={cn(
'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -745,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{displayNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagPlacement === 'sender' && tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<div className="flex items-center gap-1.5">
{hasPinned && (
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</span>
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
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}
isInJunk={currentMailboxRole === 'junk'}
+3 -1
View File
@@ -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({
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
))}
</select>
)}
@@ -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;
}
+177 -54
View File
@@ -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 (
<div
@@ -353,7 +368,7 @@ function SidebarRow({
</span>
{!isCollapsed && (
<>
<span className="flex-1 truncate">{label}</span>
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
<SidebarRowCounts
unread={unread}
total={total}
@@ -542,78 +557,120 @@ function MailboxTreeItem({
);
}
const TAG_ICON_COLOR: Record<string, string> = {
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 (
<SidebarRow
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
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<string>;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
totalCount: number;
unreadCount: number;
onToggleExpand: (keywordId: string) => void;
tagCounts: Record<string, { total: number; unread: number }>;
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 ? (
<Tag
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
fill="currentColor"
/>
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
) : (
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
);
return (
<SidebarRow
icon={tagIcon}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={unreadCount}
total={totalCount}
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
<>
<SidebarRow
icon={tagIcon}
label={label}
labelCandidates={labelCandidates}
depth={node.depth}
isSelected={isSelected}
unread={tagCounts[node.id]?.unread ?? 0}
total={tagCounts[node.id]?.total ?? 0}
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
<TagItem
key={child.id}
node={child}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
onToggleExpand={onToggleExpand}
tagCounts={tagCounts}
colorful={colorful}
/>
))}
</>
);
}
@@ -737,6 +794,8 @@ export function Sidebar({
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(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) => (
<TagItem
key={kw.id}
kw={kw}
isSelected={selectedKeyword === kw.id}
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
totalCount={tagCounts[kw.id]?.total ?? 0}
unreadCount={tagCounts[kw.id]?.unread ?? 0}
onToggleExpand={handleToggleTagExpand}
tagCounts={tagCounts}
colorful={colorfulSidebarIcons}
/>
))}
{(hiddenTagCount > 0 || showAllTags) && (
<ShowAllTagsRow
hiddenCount={hiddenTagCount}
showAll={showAllTags}
onToggle={() => setShowAllTags((prev) => !prev)}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</div>
+68 -12
View File
@@ -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}
@@ -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<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(<KeywordSettings />);
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
});
it('shows add form when add button clicked', () => {
render(<KeywordSettings />);
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(<KeywordSettings />);
fireEvent.click(screen.getByText('reset_defaults'));
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
});
it('normalizes label to id correctly', () => {
render(<KeywordSettings />);
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(<KeywordSettings />);
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(<KeywordSettings />);
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(<KeywordSettings />);
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(<KeywordSettings />);
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(<KeywordSettings />);
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(<KeywordSettings />);
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(<KeywordSettings />);
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
});
});
+154 -43
View File
@@ -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 (
<div className="flex flex-wrap gap-1.5">
{PALETTE_KEYS.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
<div className="space-y-1.5">
{KEYWORD_PALETTE_ROWS.map((row, index) => (
<div key={index} className="flex flex-wrap gap-1.5">
{row.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
))}
</div>
))}
</div>
);
@@ -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 (
<div
@@ -75,9 +114,23 @@ function KeywordRow({
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<div className="flex min-w-0 flex-1">
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
</div>
<span
ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
title={KEYWORD_PREFIX + keyword.id}
>
{shortenedKeyword}
</span>
<Select
value={getKeywordVisibility(keyword)}
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
options={visibilityOptions}
ariaLabel={t("visibility_field")}
className="text-xs py-1"
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
@@ -90,8 +143,9 @@ function KeywordRow({
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -102,37 +156,74 @@ function KeywordRow({
function KeywordEditForm({
initial,
keywords,
existingIds,
nestedTags,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
keywords: KeywordDefinition[];
existingIds: string[];
nestedTags: boolean;
onSave: (keyword: KeywordDefinition) => void;
onCancel: () => void;
}) {
const t = useTranslations("settings.keywords");
const [label, setLabel] = useState(initial?.label || "");
const [color, setColor] = useState(initial?.color || "blue");
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
const isEditing = !!initial;
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// Renaming or re-parenting a tag rewrites the keyword on every message below
// it, and this client only knows about the tags in its own settings - the
// server may hold nested keywords created elsewhere. Freeze the identity of a
// tag that has children and allow the color to change.
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
// Every tag is a candidate parent except the one being edited and anything
// already below it, which would detach the branch from its own root.
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
const collectParentOptions = (nodes: KeywordNode[]) => {
for (const node of nodes) {
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
collectParentOptions(node.children);
}
};
collectParentOptions(buildKeywordTree(keywords));
const handleSave = () => {
if (!isValid) return;
if (isLocked && initial) {
onSave({ ...initial, color });
return;
}
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
{nestedTags && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("parent_field")}
</label>
<Select
value={parentId}
onChange={setParentId}
options={parentOptions}
disabled={isLocked}
ariaLabel={t("parent_field")}
className="w-full"
/>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
@@ -141,15 +232,29 @@ function KeywordEditForm({
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLocked}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
{KEYWORD_PREFIX + normalizedId}
</p>
)}
{isLocked && (
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
)}
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
{isTooLong && (
<p className="text-xs text-destructive mt-1">
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
@@ -182,7 +287,7 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
@@ -260,12 +365,19 @@ export function KeywordSettings() {
removeKeyword(id);
};
const handleResetDefaults = () => {
reorderKeywords(DEFAULT_KEYWORDS);
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
updateKeyword(id, { visibility });
};
return (
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
<ToggleSwitch
checked={nestedTags}
onChange={(checked) => updateSetting("nestedTags", checked)}
/>
</SettingItem>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
@@ -278,7 +390,9 @@ export function KeywordSettings() {
<KeywordEditForm
key={keyword.id}
initial={keyword}
keywords={emailKeywords}
existingIds={existingIds.filter((id) => id !== keyword.id)}
nestedTags={nestedTags}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
@@ -286,11 +400,14 @@ export function KeywordSettings() {
<KeywordRow
key={keyword.id}
keyword={keyword}
keywords={emailKeywords}
nestedTags={nestedTags}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
}}
onDelete={() => handleDelete(keyword.id)}
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
@@ -303,7 +420,9 @@ export function KeywordSettings() {
{isAdding ? (
<KeywordEditForm
keywords={emailKeywords}
existingIds={existingIds}
nestedTags={nestedTags}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
@@ -320,14 +439,6 @@ export function KeywordSettings() {
<Plus className="w-3.5 h-3.5" />
{t("add_keyword")}
</button>
<button
type="button"
onClick={handleResetDefaults}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_defaults")}
</button>
</div>
)}
</div>
+1 -1
View File
@@ -245,7 +245,7 @@ export function LayoutSettings() {
/>
</SettingItem>
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
{!isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
+11 -2
View File
@@ -111,15 +111,24 @@ interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
}
export function Select({ value, onChange, options }: SelectProps) {
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
dir="auto"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
+14
View File
@@ -76,6 +76,19 @@ export function FlagES(props: FlagProps) {
);
}
/** Catalonia Senyera: four red horizontal bars on a yellow field */
export function FlagCAT(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 18" width={W} height={H} className={flagClass} {...props}>
<rect width="27" height="18" fill="#FCDD09" />
<rect y="2" width="27" height="2" fill="#DA121A" />
<rect y="6" width="27" height="2" fill="#DA121A" />
<rect y="10" width="27" height="2" fill="#DA121A" />
<rect y="14" width="27" height="2" fill="#DA121A" />
</svg>
);
}
/** Italy Green, White, Red vertical */
export function FlagIT(props: FlagProps) {
return (
@@ -309,6 +322,7 @@ export function FlagSK(props: FlagProps) {
/** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
ca: FlagCAT,
cs: FlagCS,
sk: FlagSK,
da: FlagDK,
+1
View File
@@ -9,6 +9,7 @@ import { flagComponents } from './flag-icons';
const languages = [
{ value: 'auto', label: 'Auto' },
{ value: 'ar', label: 'العربية' },
{ value: 'ca', label: 'Català' },
{ value: 'cs', label: 'Česky' },
{ value: 'sk', label: 'Slovenčina' },
{ value: 'da', label: 'Dansk' },
@@ -0,0 +1,87 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { useKeywordFormat } from '../use-keyword-format';
import { useSettingsStore, KEYWORD_PALETTE, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
{ id: 'archive', label: 'Archive', color: 'red-dark' },
];
describe('useKeywordFormat', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
describe('tagColor', () => {
it('resolves a tag to its palette entry, including the new shades', () => {
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue);
expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']);
});
it('falls back to grey for a keyword this client has no definition for', () => {
// Set on the message by another client, or its tag was deleted here.
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray);
});
it('falls back to grey for a colour that is not in the palette', () => {
useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] });
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray);
});
});
describe('sortTagIds', () => {
it('follows the order the user arranged in settings', () => {
// Settings order is work, work/clients, archive - drag-reorderable, and
// deliberately not alphabetical.
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([
'work',
'work/clients',
'archive',
]);
});
it('is stable however the keywords happen to arrive', () => {
const { result } = renderHook(() => useKeywordFormat());
const expected = ['work', 'work/clients', 'archive'];
expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected);
expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected);
});
it('follows a reordering of the settings list', () => {
useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] });
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']);
});
it('puts a tag with no local definition last, ordered by name', () => {
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([
'work',
'aa-unknown',
'zz-unknown',
]);
});
it("leaves the caller's array alone", () => {
const { result } = renderHook(() => useKeywordFormat());
const input = ['archive', 'work'];
result.current.sortTagIds(input);
expect(input).toEqual(['archive', 'work']);
});
});
});
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { useShortenedText } from '../use-shortened-text';
const CANDIDATES = ['Work/Clients/Acme/Sales', 'Work/../Acme/Sales', 'Work/.../Sales'];
/**
* Reports `width` for the observed element and measures text at 10px per
* character, so a width of N*10 fits any candidate of N characters or fewer.
*/
function stubMeasurement(width: number) {
// Implementing the interface rather than passing an anonymous class keeps the
// members the hook never calls from reading as dead code.
class StubResizeObserver implements ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
/** The hook observes once on mount; hand it `width` straight back. */
observe(target: Element) {
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', StubResizeObserver);
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: (text: string) => ({ width: text.length * 10 }),
} as unknown as CanvasRenderingContext2D);
}
function Probe({ candidates }: { candidates: string[] }) {
const [ref, text] = useShortenedText(candidates);
return <span ref={ref} data-testid="probe">{text}</span>;
}
function renderProbe(candidates: string[]): string {
render(<Probe candidates={candidates} />);
return screen.getByTestId('probe').textContent ?? '';
}
describe('useShortenedText', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns the longest candidate where the DOM cannot be measured', () => {
// No ResizeObserver: server rendering, and jsdom by default. Showing the
// whole path beats shortening it on a guess.
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('keeps the full path when the element is wide enough', () => {
stubMeasurement(230);
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('steps down only as far as the width requires', () => {
stubMeasurement(200);
expect(renderProbe(CANDIDATES)).toBe('Work/../Acme/Sales');
});
it('falls back to the shortest candidate when none of them fit', () => {
stubMeasurement(40);
expect(renderProbe(CANDIDATES)).toBe('Work/.../Sales');
});
});
+2 -3
View File
@@ -303,9 +303,8 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "x", description: "shortcuts.threads.expand_collapse" },
],
composer: [
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl/Cmd + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
],
} as const;
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useMemo } from "react";
import {
useSettingsStore,
KEYWORD_PALETTE,
FALLBACK_KEYWORD_COLOR,
type KeywordColor,
} from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
/**
* Names and colours tags for the screen, bound to the user's tag settings.
*
* Resolving the definitions and the nesting setting here rather than at every
* call site means no caller can forget the setting and render a nested name to
* someone who never asked for nesting. Subscribing to them also keeps tags in
* step the moment either changes: reading the store inside the formatter would
* leave every list stale until something else happened to re-render it.
*/
export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords);
const nested = useSettingsStore((state) => state.nestedTags);
return useMemo(
() => ({
/** The tag's display name. */
tagName: (id: string) => formatKeyword(id, keywords, nested),
/** Its progressively shorter forms, longest first, for `useShortenedText`. */
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)),
/**
* The tag's colour. Falls back to grey for a keyword this client has no
* definition for - one created on another device, or whose tag was
* deleted - so such a tag still shows rather than silently vanishing.
*/
tagColor: (id: string): KeywordColor => {
const color = keywords.find((keyword) => keyword.id === id)?.color;
return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR];
},
/**
* Tag ids in the order the user arranged them in settings.
*
* The keywords on a message arrive as an unordered JMAP map, so without
* this the same two tags can swap places between rows. A tag with no
* local definition has no place in that order, so it sorts last, by name.
*/
sortTagIds: (ids: string[]): string[] => {
const rank = (id: string) => {
const index = keywords.findIndex((keyword) => keyword.id === id);
return index === -1 ? keywords.length : index;
};
return [...ids].sort(
(a, b) =>
rank(a) - rank(b) ||
formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)),
);
},
}),
[keywords, nested],
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useMemo, useState } from "react";
/**
* Measures text the way the browser will, using the font the element actually
* renders with. One canvas is reused for every measurement.
*/
let measureContext: CanvasRenderingContext2D | null | undefined;
function measureText(text: string, font: string): number {
if (measureContext === undefined) {
measureContext = document.createElement("canvas").getContext("2d");
}
if (!measureContext) return 0;
measureContext.font = font;
return measureContext.measureText(text).width;
}
/**
* Picks the first of `candidates` that fits the element the returned ref is
* attached to, remeasuring whenever that element is resized.
*
* Candidates run longest first, so the result is the most complete one there is
* room for. A character budget cannot do this job: the columns this is used in
* are resized by the user and share their row with controls whose width depends
* on the locale, so any fixed number is either so generous that it never
* triggers or so tight that it shortens text that would have fit.
*
* Attach the ref to an element whose width does *not* depend on its own text -
* a flex child that is allowed to shrink, i.e. one with `truncate` or
* `min-w-0`. On anything else, picking a shorter candidate would change the
* width that picked it and the two would oscillate.
*
* Where measurement is unavailable - server rendering, and jsdom under test -
* this returns the first candidate, so the text is complete rather than
* arbitrarily shortened.
*/
export function useShortenedText(
candidates: string[],
): [(node: HTMLElement | null) => void, string] {
const [element, setElement] = useState<HTMLElement | null>(null);
const [box, setBox] = useState<{ width: number; font: string } | null>(null);
useEffect(() => {
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const style = window.getComputedStyle(element);
setBox({
width: entry.contentRect.width,
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
});
});
observer.observe(element);
return () => observer.disconnect();
}, [element]);
// Candidates are rebuilt on every render, so key the choice on their content.
// They must not contain a newline, which keeps this join unambiguous.
const key = candidates.join("\n");
return [
setElement,
useMemo(() => {
const options = key.split("\n");
if (!box || box.width === 0) return options[0];
return (
options.find((option) => measureText(option, box.font) <= box.width)
?? options[options.length - 1]
);
}, [key, box]),
];
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react";
import type { TagBadgeVariant } from "@/components/email/tag-badge";
/**
* Below this, a named tag beside the subject would leave the subject nothing to
* occupy, so tags move up to the sender line instead. The split list runs
* 240-600px wide and defaults to 384, so it reads that way until widened, while
* the full-width focus and bottom-pane layouts keep tags with the subject.
*/
const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
/**
* Below this there is no room to name a tag anywhere on the row, and colour
* alone has to carry it. Well under the split list's default, because the
* sender line still has room for a name long after the subject line does not.
*/
const TAG_NAME_MIN_WIDTH = 320;
export interface TagDisplay {
/** Whether a tag is named or shown as colour alone. */
variant: TagBadgeVariant;
/** Which line of a multi-line row the tags belong on. */
placement: "subject" | "sender";
}
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
/**
* How message rows should draw their tags.
*
* One value for the whole list, never per row: rows are all the same width, so
* measuring each would burn a `ResizeObserver` per virtualised row and, worse,
* let neighbours disagree - one naming its tags while the next showed dots.
*/
export const TagDisplayContext = createContext<TagDisplay>(NAMED_BESIDE_SUBJECT);
export function useTagDisplay(): TagDisplay {
return useContext(TagDisplayContext);
}
/**
* Watches a container and reports what its rows have room for. Falls back to
* naming tags beside the subject where measurement is unavailable - server
* rendering, and jsdom under test - since that is the most informative form.
*/
export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDisplay {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const measured = entries[0]?.contentRect.width;
if (measured !== undefined) setWidth(measured);
});
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return useMemo(() => {
if (width === null) return NAMED_BESIDE_SUBJECT;
return {
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
};
}, [width]);
}
+3
View File
@@ -36,6 +36,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ar':
messages = (await import('../locales/ar/common.json')).default;
break;
case 'ca':
messages = (await import('../locales/ca/common.json')).default;
break;
case 'cs':
messages = (await import('../locales/cs/common.json')).default;
break;
+1 -1
View File
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
const SUPPORTED_LOCALES = ['ar', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
const SUPPORTED_LOCALES = ['ar', 'ca', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
// Fallback locale used when the visitor's Accept-Language header does not
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
+108 -61
View File
@@ -13,107 +13,154 @@ import {
/**
* Moving mail across the own-account / shared-folder boundary, in both
* directions, and between two shared folders. The move is driven from the list
* context menu's "Move to" submenu; the authoritative check is the server-side
* mailbox the message ends up in, with the reliably-updating (own-account)
* counters checked in the UI too.
* directions, and between two shared folders (same owner and across owners).
* The move is driven from the list context menu's "Move to" submenu; the
* authoritative check is the server-side mailbox the message ends up in.
*
* Each cross-account case asserts delivery *and* that the read state survives
* (Email/copy drops keywords unless carried). Removing the source, however, is
* currently blocked by a Stalwart bug onSuccessDestroyOriginal destroys the
* copy's create-id instead of the source id, so the original is left behind
* (support.stalw.art #1150). Those source-removal checks are pinned test.fail
* until Stalwart ships the fix; same-account moves (Email/set) are unaffected.
*/
const { alice, carol } = ACCOUNTS;
const { alice, bob, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
type FolderSel = Parameters<typeof folderMailboxId>[1];
test.describe('Shared-folder moves', () => {
let ja: JmapClient; // owner
let ja: JmapClient; // owner A
let jb: JmapClient; // owner B (cross-owner shared → shared)
let jc: JmapClient; // grantee
let teamA: string;
let teamB: string;
let teamC: string; // owned by bob
test.beforeEach(async () => {
ja = await JmapClient.connect(alice.email, alice.password);
jb = await JmapClient.connect(bob.email, bob.password);
jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jb.reset();
await jc.reset();
teamA = await ja.createSharedFolder('TeamA', carol.email);
teamB = await ja.createSharedFolder('TeamB', carol.email);
teamC = await jb.createSharedFolder('TeamC', carol.email);
});
async function seedInto(mailboxId: string, subject: string, owner = ja): Promise<void> {
const acct = owner === ja ? alice : carol;
// Seed a message into a mailbox and mark it read, so a lost $seen after the
// move is observable as the moved copy coming back unread.
async function seedRead(mailboxId: string, subject: string, owner = ja): Promise<void> {
const acct = owner === ja ? alice : owner === jb ? bob : carol;
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
const m = await owner.waitForEmail(subject);
await owner.moveEmail(m.id, mailboxId);
await owner.setSeen(m.id, true);
}
test('shared folder A -> shared folder B', async ({ page }) => {
const seenOf = (m: any) => Boolean(m?.keywords?.$seen);
// Log in as carol, reveal the relevant shared owners, and move `subject` from
// `source` to `dest` via the context menu.
async function uiMove(
page: import('@playwright/test').Page,
opts: { subject: string; owners: string[]; source: FolderSel; dest: FolderSel },
): Promise<void> {
await login(page, carol);
for (const o of opts.owners) await expandSharedFolders(page, o);
const destId = await folderMailboxId(page, opts.dest);
await openFolder(page, opts.source);
await forceSync(page);
await moveEmailTo(page, opts.subject, destId);
await page.waitForTimeout(2000);
}
const inbox: FolderSel = { role: 'inbox', shared: false };
const shared = (name: string): FolderSel => ({ name, shared: true });
test('shared folder A -> shared folder B (same owner)', async ({ page }) => {
const s = subj('mv-a2b');
await seedInto(teamA, s);
await seedRead(teamA, s);
await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: shared('TeamB') });
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
await openFolder(page, { name: 'TeamA', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy();
const inB = await ja.findEmailBySubject(s, teamB);
expect(inB, 'message in TeamB').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
expect(seenOf(inB), 'read state kept').toBe(true);
});
test('shared folder B -> shared folder A', async ({ page }) => {
test('shared folder B -> shared folder A (same owner)', async ({ page }) => {
const s = subj('mv-b2a');
await seedInto(teamB, s);
await seedRead(teamB, s);
await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamB'), dest: shared('TeamA') });
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
await openFolder(page, { name: 'TeamB', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
const inA = await ja.findEmailBySubject(s, teamA);
expect(inA, 'message in TeamA').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
expect(seenOf(inA), 'read state kept').toBe(true);
});
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
// shared folder as a destination for an own-account message, but clicking it
// does NOT relocate the message across the account boundary — it stays put.
// Same in reverse (shared -> own). If cross-account moves get implemented,
// these will start passing; flip them back to plain tests then.
test.fail('own account -> shared folder', async ({ page }) => {
// Cross-account cases: delivery + read state must hold (our fix); removing the
// source is pinned test.fail below (Stalwart #1150).
test('cross-owner shared -> shared: delivers and keeps read state', async ({ page }) => {
const s = subj('mv-a2c');
await seedRead(teamA, s);
await uiMove(page, { subject: s, owners: [alice.email, bob.email], source: shared('TeamA'), dest: shared('TeamC') });
const inC = await jb.findEmailBySubject(s, teamC);
expect(inC, 'message in bob TeamC').toBeTruthy();
expect(seenOf(inC), 'read state kept').toBe(true);
});
test('own account -> shared folder: delivers and keeps read state', async ({ page }) => {
const s = subj('mv-own2sh');
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
await jc.waitForEmail(s);
const own = await jc.waitForEmail(s);
await jc.setSeen(own.id, true);
await uiMove(page, { subject: s, owners: [alice.email], source: inbox, dest: shared('TeamA') });
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
await openFolder(page, { role: 'inbox', shared: false });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
// Expected (once supported): the message moves to the owner's shared TeamA.
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
const inTeam = await ja.findEmailBySubject(s, teamA);
expect(inTeam, 'message in shared TeamA').toBeTruthy();
expect(seenOf(inTeam), 'read state kept').toBe(true);
});
test.fail('shared folder -> own account', async ({ page }) => {
test('shared folder -> own account: delivers and keeps read state', async ({ page }) => {
const s = subj('mv-sh2own');
await seedInto(teamA, s);
await seedRead(teamA, s);
await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: inbox });
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
await openFolder(page, { name: 'TeamA', shared: true });
await forceSync(page);
const inOwn = await jc.findEmailBySubject(s);
expect(inOwn, 'message in own account').toBeTruthy();
expect(seenOf(inOwn), 'read state kept').toBe(true);
});
await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
// Pinned failing: Stalwart's onSuccessDestroyOriginal leaves the original in
// place on a cross-account copy (support.stalw.art #1150). Un-pin once fixed
// upstream (our copyEmailAcrossAccounts already requests the destroy).
test.describe('source is removed after a cross-account move', () => {
test.fail(true, 'blocked by Stalwart #1150 (onSuccessDestroyOriginal destroys wrong id)');
// Expected (once supported): the message arrives in carol's own Inbox.
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
test('cross-owner shared -> shared', async ({ page }) => {
const s = subj('rm-a2c');
await seedRead(teamA, s);
await uiMove(page, { subject: s, owners: [alice.email, bob.email], source: shared('TeamA'), dest: shared('TeamC') });
expect(await ja.findEmailBySubject(s, teamA), 'original left alice TeamA').toBeFalsy();
});
test('own account -> shared folder', async ({ page }) => {
const s = subj('rm-own2sh');
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
const own = await jc.waitForEmail(s);
await jc.setSeen(own.id, true);
await uiMove(page, { subject: s, owners: [alice.email], source: inbox, dest: shared('TeamA') });
expect(await jc.findEmailBySubject(s), 'original left own account').toBeFalsy();
});
test('shared folder -> own account', async ({ page }) => {
const s = subj('rm-sh2own');
await seedRead(teamA, s);
await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: inbox });
expect(await ja.findEmailBySubject(s, teamA), 'original left shared TeamA').toBeFalsy();
});
});
});
+47
View File
@@ -232,6 +232,53 @@ describe('dev-jmap mock server', () => {
});
});
describe('POST /api - request limits', () => {
it('should refuse a request with more method calls than it advertises', async () => {
const methodCalls = Array.from({ length: 17 }, (_, i) => [
'Email/query',
{ accountId: 'dev-account-001', limit: 0, calculateTotal: true },
`c${i}`,
]);
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ methodCalls }),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
expect(res.status).toBe(400);
expect(data.type).toBe('urn:ietf:params:jmap:error:limit');
expect(data.limit).toBe('maxCallsInRequest');
});
it('should reject an over-sized /set with requestTooLarge', async () => {
const destroy = Array.from({ length: 501 }, (_, i) => `email-${i}`);
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ methodCalls: [['Email/set', { accountId: 'dev-account-001', destroy }, '0']] }),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
expect(res.status).toBe(200);
expect(data.methodResponses[0][0]).toBe('error');
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
});
it('should reject an over-sized /get with requestTooLarge', async () => {
const ids = Array.from({ length: 501 }, (_, i) => `email-${i}`);
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ methodCalls: [['Email/get', { accountId: 'dev-account-001', ids }, '0']] }),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
expect(data.methodResponses[0][0]).toBe('error');
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
});
});
describe('POST /upload', () => {
it('should return a fake blob response', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment';
import type { Email } from '@/lib/jmap/types';
// Pin TZ so the local-time date rendering in the filename test is deterministic,
// restoring it after so this doesn't leak into other test files in the same worker.
let originalTZ: string | undefined;
beforeAll(() => {
originalTZ = process.env.TZ;
process.env.TZ = 'UTC';
});
afterAll(() => {
// process.env coerces to strings, so `= undefined` would leave the literal
// string "undefined" behind when TZ was originally unset - delete instead.
if (originalTZ === undefined) delete process.env.TZ;
else process.env.TZ = originalTZ;
});
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'e1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
size: 12345,
receivedAt: '2026-07-26T22:25:22Z',
subject: 'Your waste service day is changing',
hasAttachment: false,
blobId: 'blob123',
...overrides,
};
}
describe('buildForwardAsAttachmentPayload', () => {
it('returns null when the email has no blobId', () => {
const email = makeEmail({ blobId: undefined });
expect(buildForwardAsAttachmentPayload(email, 'Fwd:')).toBeNull();
});
it('prefixes the subject using the given forward prefix', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('Fwd: Missed spam example');
});
it('builds a message/rfc822 attachment referencing the email\'s own blobId, not a new upload', () => {
const email = makeEmail({ blobId: 'the-real-blob-id', size: 26489 });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment).toEqual({
blobId: 'the-real-blob-id',
name: expect.stringMatching(/\.eml$/),
type: 'message/rfc822',
size: 26489,
});
});
it('is idempotent - repeated forwarding does not stack prefixes', () => {
const email = makeEmail({ subject: 'Fwd: already forwarded once' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('Fwd: already forwarded once');
});
it('leaves the subject blank (not just the bare prefix) for a subject-less message, matching normal Forward', () => {
const email = makeEmail({ subject: undefined });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('');
});
it('applies user space/case transforms but ignores a custom filename template, unlike "Export as .eml"', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:', {
template: 'custom-{subject}',
lowercase: true,
spaceReplacement: 'dash',
});
expect(payload?.attachment.name).toBe('2026-07-26-22.25.22-missed-spam-example.eml');
});
it('uses a dash between date and subject by default', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment.name).toBe('2026-07-26 22.25.22-Missed spam example.eml');
});
it('never includes from/to in the filename, even with the default template, to avoid leaking names to the recipient', () => {
const email = makeEmail({
subject: 'Missed spam example',
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
to: [{ name: "'Bobby'", email: 'bob@example.com' }],
});
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment.name).not.toContain('Alice');
expect(payload?.attachment.name).not.toContain('Bobby');
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
function makeSession() {
return {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Stand-in for a Stalwart mailbox: Email/query returns one page of remaining
* ids, Email/set destroys them. `includeTotal` mirrors the server's freedom to
* omit `total` when the query did not ask for `calculateTotal` (RFC 8620 5.5).
*/
function makeMailboxServer(opts: {
count: number;
includeTotal?: boolean;
destroyFails?: boolean;
}) {
let remaining = Array.from({ length: opts.count }, (_, i) => `email-${i}`);
const requests: number[] = [];
const handler = async (_url: string, init: RequestInit): Promise<Response> => {
const body = JSON.parse(init.body as string);
const [, queryArgs] = body.methodCalls[0];
const limit: number = queryArgs.limit;
const page = remaining.slice(0, limit);
requests.push(page.length);
const destroyed = opts.destroyFails ? [] : page;
remaining = remaining.slice(destroyed.length);
return jsonResponse({
methodResponses: [
['Email/query', { ids: page, ...(opts.includeTotal ? { total: page.length } : {}) }, '0'],
['Email/set', { destroyed, notDestroyed: {} }, '1'],
],
});
};
return { handler, requests, remainingCount: () => remaining.length };
}
describe('JMAPClient.emptyMailbox', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
async function connectedClient(): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
it('destroys every email in a mailbox larger than one batch', async () => {
const client = await connectedClient();
const server = makeMailboxServer({ count: 1200 });
fetchSpy.mockImplementation(server.handler as never);
const destroyed = await client.emptyMailbox('mailbox-1');
expect(destroyed).toBe(1200);
expect(server.remainingCount()).toBe(0);
expect(server.requests).toEqual([500, 500, 200]);
});
// Regression for #711: the loop used to stop after one batch when the server
// omitted `total`, leaving folders with thousands of emails nearly full.
it('keeps paging when the server omits Email/query total', async () => {
const client = await connectedClient();
const server = makeMailboxServer({ count: 2300, includeTotal: false });
fetchSpy.mockImplementation(server.handler as never);
const destroyed = await client.emptyMailbox('mailbox-1');
expect(destroyed).toBe(2300);
expect(server.remainingCount()).toBe(0);
});
it('issues a final confirming query when the count is an exact multiple of the batch size', async () => {
const client = await connectedClient();
const server = makeMailboxServer({ count: 1000 });
fetchSpy.mockImplementation(server.handler as never);
const destroyed = await client.emptyMailbox('mailbox-1');
expect(destroyed).toBe(1000);
expect(server.requests).toEqual([500, 500, 0]);
});
it('stops instead of looping forever when the server refuses to destroy', async () => {
const client = await connectedClient();
const server = makeMailboxServer({ count: 1200, destroyFails: true });
fetchSpy.mockImplementation(server.handler as never);
const destroyed = await client.emptyMailbox('mailbox-1');
expect(destroyed).toBe(0);
expect(server.requests).toEqual([500]);
});
it('returns zero without extra requests for an already empty mailbox', async () => {
const client = await connectedClient();
const server = makeMailboxServer({ count: 0 });
fetchSpy.mockImplementation(server.handler as never);
const destroyed = await client.emptyMailbox('mailbox-1');
expect(destroyed).toBe(0);
expect(server.requests).toEqual([0]);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { batched, itemsPerRequest } from '../jmap/request-limits';
// Stalwart allows 16 method calls and 500 objects per request by default. A
// batch built from a list the user controls - tags, a multi-select, an import -
// reaches those ceilings with ordinary use, and going over fails the *whole*
// request: nine tags used to blank every tag badge in the sidebar.
function makeSession(core: Record<string, number> = {}) {
return {
capabilities: { 'urn:ietf:params:jmap:core': core },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
/** RFC 8620 §3.6.1: an over-sized request is refused whole, before any method runs. */
function limitErrorResponse(limit: string): Response {
return new Response(
JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', status: 400, limit }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
describe('batched', () => {
it('returns one batch when everything fits', () => {
expect(batched([1, 2, 3], 5)).toEqual([[1, 2, 3]]);
});
it('splits into consecutive batches of at most `size`', () => {
expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it('returns nothing for an empty list', () => {
expect(batched([], 10)).toEqual([]);
});
it('never produces an empty batch for a nonsensical size', () => {
expect(batched([1, 2], 0)).toEqual([[1], [2]]);
expect(batched([1, 2], -5)).toEqual([[1], [2]]);
});
});
describe('itemsPerRequest', () => {
it('divides the call budget by the cost of one item', () => {
expect(itemsPerRequest(16, 2)).toBe(8);
expect(itemsPerRequest(16, 1)).toBe(16);
expect(itemsPerRequest(50, 3)).toBe(16);
});
it('always allows at least one item, however expensive', () => {
expect(itemsPerRequest(1, 2)).toBe(1);
});
});
describe('JMAPClient request limits', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
fetchSpy.mockRestore();
vi.restoreAllMocks();
});
async function connectedClient(core?: Record<string, number>): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core)));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
/** Records the method calls of every request the client makes. */
function recordRequests(reply: (methodCalls: Array<[string, Record<string, unknown>, string]>) => unknown) {
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
sent.push(body.methodCalls);
return jsonResponse(reply(body.methodCalls));
}) as never);
return sent;
}
describe('getTagCounts', () => {
// Two Email/query calls per tag: nine tags is 18 calls against a ceiling of 16.
const tags = Array.from({ length: 9 }, (_, i) => `tag-${i}`);
it('splits the tags so no request exceeds maxCallsInRequest', async () => {
const client = await connectedClient({ maxCallsInRequest: 16 });
const sent = recordRequests((methodCalls) => ({
methodResponses: methodCalls.map(([, , callId], i) => [
'Email/query',
{ total: i + 1 },
callId,
]),
}));
const counts = await client.getTagCounts(tags);
expect(sent.map(calls => calls.length)).toEqual([16, 2]);
expect(Object.keys(counts)).toEqual(tags);
expect(counts['tag-8']).toEqual({ total: 1, unread: 2 });
});
it('keeps the tags of the batches that did succeed when one is refused', async () => {
const client = await connectedClient({ maxCallsInRequest: 16 });
let call = 0;
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
if (call++ === 0) return limitErrorResponse('maxCallsInRequest');
return jsonResponse({
methodResponses: body.methodCalls.map(([, , callId]: [string, unknown, string]) => [
'Email/query', { total: 7 }, callId,
]),
});
}) as never);
const counts = await client.getTagCounts(tags);
expect(Object.keys(counts)).toEqual(['tag-8']);
expect(counts['tag-8']).toEqual({ total: 7, unread: 7 });
});
it('honours a lower ceiling advertised by the server', async () => {
const client = await connectedClient({ maxCallsInRequest: 4 });
const sent = recordRequests((methodCalls) => ({
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 0 }, callId]),
}));
await client.getTagCounts(tags);
expect(sent.map(calls => calls.length)).toEqual([4, 4, 4, 4, 2]);
});
});
describe('getCategoryUnreadCounts', () => {
it('splits the tabs across requests and keeps every tab id', async () => {
const client = await connectedClient({ maxCallsInRequest: 16 });
const tabs = Array.from({ length: 20 }, (_, i) => ({ id: `tab-${i}`, filter: null }));
const sent = recordRequests((methodCalls) => ({
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 3 }, callId]),
}));
const counts = await client.getCategoryUnreadCounts('inbox', tabs);
expect(sent.map(calls => calls.length)).toEqual([16, 4]);
expect(Object.keys(counts)).toHaveLength(20);
expect(counts['tab-19']).toBe(3);
});
});
describe('Email/set batches', () => {
const ids = Array.from({ length: 1200 }, (_, i) => `email-${i}`);
it('splits batchDeleteEmails at maxObjectsInSet', async () => {
const client = await connectedClient({ maxObjectsInSet: 500 });
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { destroyed: [] }, '0']] }));
await client.batchDeleteEmails(ids);
expect(sent.map(calls => (calls[0][1].destroy as string[]).length)).toEqual([500, 500, 200]);
});
it('splits batchMarkAsRead at maxObjectsInSet', async () => {
const client = await connectedClient({ maxObjectsInSet: 500 });
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
await client.batchMarkAsRead(ids, true);
const updated = sent.flatMap(calls => Object.keys(calls[0][1].update as object));
expect(sent).toHaveLength(3);
expect(updated).toEqual(ids);
});
it('splits batchMoveEmails at a ceiling the server lowered', async () => {
const client = await connectedClient({ maxObjectsInSet: 100 });
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
await client.batchMoveEmails(ids, 'mailbox-2');
expect(sent).toHaveLength(12);
expect(Object.keys(sent[0][0][1].update as object)).toHaveLength(100);
});
});
describe('Email/get batches', () => {
it('splits getSomeEmails at maxObjectsInGet and returns every message', async () => {
const client = await connectedClient({ maxObjectsInGet: 500 });
const sent = recordRequests((methodCalls) => ({
methodResponses: [[
'Email/get',
{
list: (methodCalls[0][1].ids as string[]).map(id => ({
id,
receivedAt: '2026-03-14T10:00:00Z',
})),
},
'0',
]],
}));
const emails = await client.getSomeEmails(Array.from({ length: 1100 }, (_, i) => `email-${i}`));
expect(sent.map(calls => (calls[0][1].ids as string[]).length)).toEqual([500, 500, 100]);
expect(emails).toHaveLength(1100);
});
});
it('falls back to the documented defaults when the session advertises no limits', async () => {
const client = await connectedClient();
expect(client.getMaxObjectsInGet()).toBe(500);
expect(client.getMaxObjectsInSet()).toBe(500);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import type { KeywordDefinition } from "@/stores/settings-store";
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
// Work
// Clients
// Acme
// Personal
const KEYWORDS: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/personal", "Personal"),
];
describe("formatKeyword with nesting on", () => {
it("joins the display name of every level", () => {
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
});
it("returns the plain display name for a tag with one level", () => {
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
});
it("falls back to the raw level for one this client does not know", () => {
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
expect(formatKeyword("unknown", [], true)).toBe("unknown");
});
});
describe("formatKeyword with nesting off", () => {
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
// The setting says a slash means nothing, so an id that happens to contain
// one - from before it was turned off, or from another client - is a single
// opaque token rather than a hierarchy.
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
});
it("falls back to the whole id when the tag has no definition", () => {
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
});
it("offers no shortening, leaving the markup to clip", () => {
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
.toEqual(["Acme"]);
});
});
describe("keywordRenderings", () => {
it("shortens by one intermediate level at a time, outermost first", () => {
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
"Work/Clients/Acme/EU/Sales",
"Work/../Acme/EU/Sales",
"Work/.../EU/Sales",
"Work/.../Sales",
]);
});
it("collapses to a single ... as soon as the run covers more than one level", () => {
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
"Work/Clients/Acme/Sales",
"Work/../Acme/Sales",
"Work/.../Sales",
]);
});
it("uses .. for a lone intermediate level, never ...", () => {
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
"Work/Clients/Acme",
"Work/../Acme",
]);
});
it("has nothing to shorten without an intermediate level", () => {
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
});
it("drops a rendering that would not come out shorter", () => {
// "../" costs as much as the level it replaces, so shortening buys nothing.
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
});
});
// How the components use the two together: resolve a tag to its display names,
// then hand the ladder to `useShortenedText` to pick a rung.
describe("keywordRenderings over formatKeywordLabels", () => {
it("shortens a display name by the same ladder as an id", () => {
const deep: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/clients/acme/eu", "Europe"),
];
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
"Work/Clients/Acme/Europe",
"Work/../Acme/Europe",
"Work/.../Europe",
]);
});
it("treats a slash inside one display name as part of that name, not a level", () => {
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
// Two levels, so there is no intermediate level to shorten.
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
"Work/Acme/R&D",
]);
});
});
+183
View File
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
MAX_KEYWORD_ID_LENGTH,
buildKeywordTree,
composeKeywordId,
countKeywordNodes,
filterKeywordTree,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
normalizeKeywordLevel,
} from "@/lib/keyword-nesting";
import type { KeywordDefinition } from "@/stores/settings-store";
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
// Work
// Clients
// Acme
// Personal
const KEYWORDS: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/personal", "Personal"),
];
describe("normalizeKeywordLevel", () => {
it("lowercases and folds unsupported characters into single dashes", () => {
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
});
it("treats a slash as part of the name, not as a level", () => {
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
});
it("returns an empty string when nothing usable is left", () => {
expect(normalizeKeywordLevel(" ")).toBe("");
expect(normalizeKeywordLevel("!!!")).toBe("");
});
});
describe("composeKeywordId", () => {
it("returns a bare slug at the top level", () => {
expect(composeKeywordId(null, "Work")).toBe("work");
expect(composeKeywordId("", "Work")).toBe("work");
});
it("appends the slug below the parent", () => {
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
});
it("never produces a trailing separator for an unusable name", () => {
expect(composeKeywordId("work", "!!!")).toBe("");
});
});
describe("keywordLevels", () => {
it("splits an id into its levels", () => {
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
expect(keywordLevels("work")).toEqual(["work"]);
});
});
describe("getParentKeywordId", () => {
it("drops the last level", () => {
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
});
it("returns null for a top-level tag", () => {
expect(getParentKeywordId("work")).toBeNull();
});
});
describe("isKeywordDescendant", () => {
it("matches anything below the ancestor", () => {
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
});
it("does not match the ancestor itself or a shared name prefix", () => {
expect(isKeywordDescendant("work", "work")).toBe(false);
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
});
});
describe("hasChildKeywords", () => {
it("reports whether any defined tag sits below the given one", () => {
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
});
});
describe("MAX_KEYWORD_ID_LENGTH", () => {
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
});
});
describe("buildKeywordTree", () => {
it("nests each tag under its parent and records the depth", () => {
const [work] = buildKeywordTree(KEYWORDS);
expect(work.id).toBe("work");
expect(work.depth).toBe(0);
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
const clients = work.children[0];
expect(clients.depth).toBe(1);
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
expect(clients.children[0].depth).toBe(2);
});
it("keeps the manual order within a level", () => {
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
const [work] = buildKeywordTree(reordered);
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
});
it("keeps a tag whose parent is not defined at the root", () => {
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
expect(orphan).toHaveLength(1);
expect(orphan[0].id).toBe("work/clients/acme");
expect(orphan[0].depth).toBe(0);
});
it("returns every tag as a root when no id describes a hierarchy", () => {
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
});
});
describe("filterKeywordTree", () => {
const tree = buildKeywordTree(KEYWORDS);
it("drops the nodes the predicate rejects", () => {
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
const [work] = kept;
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
});
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
const [work] = kept;
expect(work.id).toBe("work");
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
});
it("keeps the depth of a surviving node so its indentation does not shift", () => {
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
expect(kept[0].children[0].children[0].depth).toBe(2);
});
it("returns nothing when the predicate rejects everything", () => {
expect(filterKeywordTree(tree, () => false)).toEqual([]);
});
it("leaves the original tree untouched", () => {
filterKeywordTree(tree, (node) => node.id === "work");
expect(countKeywordNodes(tree)).toBe(4);
});
});
describe("countKeywordNodes", () => {
it("counts every level, not just the roots", () => {
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
expect(countKeywordNodes([])).toBe(0);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients';
const OWN = ['me@example.com', 'info@example.com'];
const emails = (list: { email?: string }[]) => list.map((r) => r.email);
describe('buildReplyRecipients', () => {
describe('received message', () => {
const received = {
from: [{ email: 'bob@other.com', name: 'Bob' }],
to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }],
cc: [{ email: 'dave@other.com' }],
};
it('replies to the sender', () => {
const { to, cc } = buildReplyRecipients(received, 'reply', OWN);
expect(emails(to)).toEqual(['bob@other.com']);
expect(cc).toEqual([]);
});
it('prefers the Reply-To header over From', () => {
const { to } = buildReplyRecipients(
{ ...received, replyToAddresses: [{ email: 'list@other.com' }] },
'reply',
OWN,
);
expect(emails(to)).toEqual(['list@other.com']);
});
it('reply-all keeps the other recipients and drops our own address', () => {
const { to, cc } = buildReplyRecipients(received, 'replyAll', OWN);
expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
expect(emails(cc)).toEqual(['dave@other.com']);
});
it('reply-all drops our own address even with +tag sub-addressing', () => {
const { to } = buildReplyRecipients(
{ ...received, to: [{ email: 'me+newsletter@example.com' }, { email: 'carol@other.com' }] },
'replyAll',
OWN,
);
expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
});
});
describe('self-sent message (#703)', () => {
const sent = {
from: [{ email: 'me@example.com', name: 'Me' }],
to: [{ email: 'bob@other.com', name: 'Bob' }],
cc: [{ email: 'carol@other.com' }],
};
it('replies to the original recipient, not to ourselves', () => {
const { to, cc } = buildReplyRecipients(sent, 'reply', OWN);
expect(emails(to)).toEqual(['bob@other.com']);
expect(cc).toEqual([]);
});
it('reply-all restores the original To and Cc', () => {
const { to, cc } = buildReplyRecipients(sent, 'replyAll', OWN);
expect(emails(to)).toEqual(['bob@other.com']);
expect(emails(cc)).toEqual(['carol@other.com']);
});
it('recognises the sending identity through +tag sub-addressing', () => {
const { to } = buildReplyRecipients(
{ ...sent, from: [{ email: 'me+project@example.com' }] },
'reply',
OWN,
);
expect(emails(to)).toEqual(['bob@other.com']);
});
it('ignores our own Reply-To header so the reply leaves our mailbox', () => {
const { to } = buildReplyRecipients(
{ ...sent, replyToAddresses: [{ email: 'info@example.com' }] },
'reply',
OWN,
);
expect(emails(to)).toEqual(['bob@other.com']);
});
it('keeps a self-addressed recipient we chose ourselves', () => {
const { to } = buildReplyRecipients(
{ ...sent, to: [{ email: 'info@example.com' }] },
'reply',
OWN,
);
expect(emails(to)).toEqual(['info@example.com']);
});
it('falls back to the sender when there is no visible recipient (Bcc-only)', () => {
const { to } = buildReplyRecipients({ ...sent, to: [], cc: [] }, 'reply', OWN);
expect(emails(to)).toEqual(['me@example.com']);
});
it('keeps the display names of the original recipients', () => {
const { to } = buildReplyRecipients(sent, 'reply', OWN);
expect(to[0]).toEqual({ email: 'bob@other.com', name: 'Bob' });
});
});
it('returns nothing without a source message', () => {
expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] });
});
it('treats a message as foreign when no identity matches', () => {
expect(isSelfSent({ from: [{ email: 'bob@other.com' }] }, OWN)).toBe(false);
expect(isSelfSent({ from: [{ email: 'ME@Example.com ' }] }, OWN)).toBe(true);
expect(isSelfSent({ from: [] }, OWN)).toBe(false);
expect(isSelfSent(undefined, OWN)).toBe(false);
});
});
+87 -30
View File
@@ -4,8 +4,10 @@ import {
sortThreadGroups,
getThreadParticipants,
mergeThreadEmails,
getEmailColorTag,
getThreadColorTag,
getEmailTagId,
getEmailTagIds,
getThreadTagId,
getThreadTagIds,
} from '../thread-utils';
import type { Email, ThreadGroup } from '../jmap/types';
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
});
});
describe('getEmailColorTag', () => {
it('returns label from $label: keyword', () => {
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
describe('getEmailTagIds', () => {
it('gathers every tag set on the message', () => {
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
.toEqual(['red', 'work']);
});
it('returns label from legacy $color: keyword', () => {
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
it('reads the legacy prefix alongside the current one', () => {
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
});
it('returns null when no color keyword', () => {
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailColorTag(undefined)).toBeNull();
it('reports a tag written under both prefixes once', () => {
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
});
it('ignores keywords set to false', () => {
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
});
it('prefers $label: over $color: when both exist', () => {
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
});
it('handles custom keyword ids', () => {
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
});
it('returns null for empty keywords object', () => {
expect(getEmailColorTag({})).toBeNull();
it('is empty for an untagged message or none at all', () => {
expect(getEmailTagIds({ $seen: true })).toEqual([]);
expect(getEmailTagIds(undefined)).toEqual([]);
});
});
describe('getThreadColorTag', () => {
describe('getEmailTagId', () => {
it('returns label from $label: keyword', () => {
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
});
it('returns label from legacy $color: keyword', () => {
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
});
it('returns null when no color keyword', () => {
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailTagId(undefined)).toBeNull();
});
it('ignores keywords set to false', () => {
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
});
it('prefers $label: over $color: when both exist', () => {
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
});
it('handles custom keyword ids', () => {
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
});
it('returns null for empty keywords object', () => {
expect(getEmailTagId({})).toBeNull();
});
});
describe('getThreadTagId', () => {
it('returns first color found across thread emails', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('blue');
expect(getThreadTagId(emails)).toBe('blue');
});
it('returns null when no emails have color tags', () => {
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
];
expect(getThreadColorTag(emails)).toBeNull();
expect(getThreadTagId(emails)).toBeNull();
});
it('returns first tag from earliest tagged email', () => {
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('red');
expect(getThreadTagId(emails)).toBe('red');
});
it('returns legacy tag from thread emails', () => {
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
];
expect(getThreadColorTag(emails)).toBe('green');
expect(getThreadTagId(emails)).toBe('green');
});
it('returns null for empty email array', () => {
expect(getThreadColorTag([])).toBeNull();
expect(getThreadTagId([])).toBeNull();
});
});
describe('getThreadTagIds', () => {
it('gathers the tags of every message in the thread', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
];
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
});
it('reports a tag shared by several messages once', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
];
expect(getThreadTagIds(emails)).toEqual(['red']);
});
it('reads the legacy prefix alongside the current one', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
];
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
});
it('is empty for an untagged or empty thread', () => {
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
expect(getThreadTagIds([])).toEqual([]);
});
});
+42
View File
@@ -445,6 +445,48 @@ describe("generateVCard", () => {
const vcf = generateVCard([contact]);
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
});
it("uses the organization name as FN for organization cards (issue #701)", () => {
const contact: ContactCard = {
id: "c4",
addressBookIds: {},
kind: "org",
name: { full: "Acme Corp" },
organizations: { o0: { name: "Acme Corp" } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("KIND:org");
expect(vcf).toContain("FN:Acme Corp");
expect(vcf).toContain("ORG:Acme Corp");
});
it("falls back to ORG for FN when the card has no name at all", () => {
const contact: ContactCard = {
id: "c5",
addressBookIds: {},
kind: "org",
organizations: { o0: { name: "Acme Corp" } },
};
expect(generateVCard([contact])).toContain("FN:Acme Corp");
});
});
describe("organization-only cards (issue #701)", () => {
it("keeps a vCard that has only an organization name", () => {
const parsed = parseVCard([
"BEGIN:VCARD",
"VERSION:4.0",
"KIND:org",
"ORG:Acme Corp",
"END:VCARD",
].join("\r\n"));
expect(parsed).toHaveLength(1);
expect(parsed[0].kind).toBe("org");
expect(parsed[0].organizations?.o0.name).toBe("Acme Corp");
});
});
describe("round-trip: parse → generate → parse", () => {
+7 -5
View File
@@ -56,7 +56,7 @@ export class DemoJMAPClient implements IJMAPClient {
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:vacationresponse': {},
@@ -71,6 +71,7 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; }
getMaxObjectsInSet(): number { return 500; }
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; }
@@ -1046,7 +1047,7 @@ export class DemoJMAPClient implements IJMAPClient {
const node: FileNode = {
id: generateDemoId('file'),
parentId, name, type: 'd', blobId: null, size: 0,
created: new Date().toISOString(), updated: new Date().toISOString(),
created: new Date().toISOString(), modified: new Date().toISOString(),
};
this.data.fileNodes.push(node);
return node;
@@ -1056,7 +1057,7 @@ export class DemoJMAPClient implements IJMAPClient {
const node: FileNode = {
id: generateDemoId('file'),
parentId, name, type, blobId, size,
created: new Date().toISOString(), updated: new Date().toISOString(),
created: new Date().toISOString(), modified: new Date().toISOString(),
};
this.data.fileNodes.push(node);
return node;
@@ -1064,7 +1065,7 @@ export class DemoJMAPClient implements IJMAPClient {
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
const node = this.data.fileNodes.find(n => n.id === id);
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
if (node) Object.assign(node, updates, { modified: new Date().toISOString() });
}
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
@@ -1072,7 +1073,7 @@ export class DemoJMAPClient implements IJMAPClient {
for (const [id, patch] of Object.entries(updates)) {
const node = this.data.fileNodes.find(n => n.id === id);
if (node) {
Object.assign(node, patch, { updated: new Date().toISOString() });
Object.assign(node, patch, { modified: new Date().toISOString() });
updated.push(id);
}
}
@@ -1094,6 +1095,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── S/MIME raw-email helpers ──────────────────────────────────
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async copyEmailAcrossAccounts(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async submitRawEmail(blob: Blob,
identityId: string,
+8 -8
View File
@@ -12,7 +12,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: null,
size: 0,
created: demoDate(-30),
updated: demoDate(-2),
modified: demoDate(-2),
},
{
id: 'demo-file-photos',
@@ -22,7 +22,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: null,
size: 0,
created: demoDate(-30),
updated: demoDate(-5),
modified: demoDate(-5),
},
// Documents contents
@@ -34,7 +34,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: 'demo-blob-file-1',
size: 2150,
created: demoDate(-7),
updated: demoDate(-2),
modified: demoDate(-2),
},
{
id: 'demo-file-quarterly-report',
@@ -44,7 +44,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: 'demo-blob-file-2',
size: 148480,
created: demoDate(-14),
updated: demoDate(-14),
modified: demoDate(-14),
},
{
id: 'demo-file-todo',
@@ -54,7 +54,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: 'demo-blob-file-3',
size: 410,
created: demoDate(-3),
updated: demoDate(-1),
modified: demoDate(-1),
},
// Photos contents
@@ -66,7 +66,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: 'demo-blob-file-4',
size: 1258291,
created: demoDate(-10),
updated: demoDate(-10),
modified: demoDate(-10),
},
{
id: 'demo-file-team-photo',
@@ -76,7 +76,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: 'demo-blob-file-5',
size: 911360,
created: demoDate(-21),
updated: demoDate(-21),
modified: demoDate(-21),
},
// Root-level file
@@ -88,7 +88,7 @@ export function createDemoFileNodes(): FileNode[] {
blobId: 'demo-blob-file-6',
size: 68608,
created: demoDate(-5),
updated: demoDate(-1),
modified: demoDate(-1),
},
];
}
+58
View File
@@ -0,0 +1,58 @@
import type { Email } from "@/lib/jmap/types";
import { buildForwardSubject } from "@/lib/subject-prefix";
import { emailExportFilename, type EmailFilenameOptions } from "@/lib/download-filename";
export interface ForwardAsAttachmentEntry {
blobId: string;
name: string;
type: "message/rfc822";
size: number;
}
export interface ForwardAsAttachmentPayload {
subject: string;
attachment: ForwardAsAttachmentEntry;
}
/**
* Build the subject and synthetic attachment entry for forwarding a
* message as a message/rfc822 attachment instead of inline-quoted text
* (e.g. reporting spam to an upstream gateway that expects the raw
* original as an attachment, or preserving exact formatting/headers).
*
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
* not per-email, so the same blobId a message already has can be attached
* to a brand new outgoing email directly.
*
* `filenameOptions`, when passed, carries the user's configured space/case/
* diacritics transforms (see useSettingsStore's filenameSpaceReplacement
* and friends) for consistency with "Export as .eml" / drag-out. Its
* `template`, if any, is ignored: this attachment goes out to a possibly
* external recipient (spam gateway, another person), so the filename is
* always just "{date}-{subject}.eml" - never the user's own from/to naming
* template, which could otherwise leak sender/recipient names into an
* attachment filename visible to that recipient.
*
* Returns null when the email has no blobId (nothing to reference).
*/
export function buildForwardAsAttachmentPayload(
email: Email,
forwardPrefix: string,
filenameOptions?: EmailFilenameOptions,
): ForwardAsAttachmentPayload | null {
if (!email.blobId) return null;
return {
// Match the normal Forward flow's getInitialSubject(), which leaves the
// subject blank rather than prefix-only when the original has none -
// buildForwardSubject("", prefix) would otherwise return just the bare
// prefix (e.g. "Fwd:") for a subject-less message.
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
attachment: {
blobId: email.blobId,
name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }),
type: "message/rfc822",
size: email.size,
},
};
}
+9
View File
@@ -31,6 +31,7 @@ export interface IJMAPClient {
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
getMaxObjectsInSet(): number;
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null;
@@ -345,4 +346,12 @@ export interface IJMAPClient {
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
/**
* Server-side move of one email across accounts reachable through THIS client
* (JMAP `Email/copy` + destroy-original). Used for delegated/shared folders,
* where the two accounts share a client but a client can't stage a blob in a
* delegated account (so the blob copy+import path doesn't work). Returns the
* new email id in the destination account.
*/
copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string): Promise<string>;
}
+376 -255
View File
@@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
import { batched, itemsPerRequest } from "./request-limits";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
@@ -606,31 +607,32 @@ export class JMAPClient implements IJMAPClient {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: emailsId,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
const emails: Email[] = [];
const getResponse = response.methodResponses?.[0]?.[1];
for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: batchIds,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
const emails = (getResponse.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
const getResponse = response.methodResponses?.[0]?.[1];
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
emails.push(...((getResponse.list || []) as Email[]));
}
return emails;
}
return [];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return emails;
} catch (error) {
console.error('Failed to get specific emails:', error);
return [];
@@ -1266,56 +1268,62 @@ export class JMAPClient implements IJMAPClient {
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
if (tagIds.length === 0) return {};
try {
const methodCalls: JMAPMethodCall[] = [];
for (let i = 0; i < tagIds.length; i++) {
const keyword = `$label:${tagIds[i]}`;
// Total count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: { hasKeyword: keyword },
limit: 0,
calculateTotal: true,
}, `total_${i}`]);
// Unread count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: {
operator: "AND",
conditions: [
{ hasKeyword: keyword },
{ notKeyword: "$seen" },
],
},
limit: 0,
calculateTotal: true,
}, `unread_${i}`]);
const result: Record<string, { total: number; unread: number }> = {};
const CALLS_PER_TAG = 2;
const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG);
for (const batch of batched(tagIds, perRequest)) {
try {
const methodCalls: JMAPMethodCall[] = [];
for (let i = 0; i < batch.length; i++) {
const keyword = `$label:${batch[i]}`;
// Total count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: { hasKeyword: keyword },
limit: 0,
calculateTotal: true,
}, `total_${i}`]);
// Unread count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: {
operator: "AND",
conditions: [
{ hasKeyword: keyword },
{ notKeyword: "$seen" },
],
},
limit: 0,
calculateTotal: true,
}, `unread_${i}`]);
}
const response = await this.request(methodCalls);
for (let i = 0; i < batch.length; i++) {
const totalResp = response.methodResponses?.[i * 2]?.[1];
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
result[batch[i]] = {
total: totalResp?.total ?? 0,
unread: unreadResp?.total ?? 0,
};
}
} catch (error) {
console.error('Failed to get tag counts:', error);
}
const response = await this.request(methodCalls);
const result: Record<string, { total: number; unread: number }> = {};
for (let i = 0; i < tagIds.length; i++) {
const totalResp = response.methodResponses?.[i * 2]?.[1];
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
result[tagIds[i]] = {
total: totalResp?.total ?? 0,
unread: unreadResp?.total ?? 0,
};
}
return result;
} catch (error) {
console.error('Failed to get tag counts:', error);
return {};
}
return result;
}
/**
* Per-tab unread counts for message-list category tabs. One Email/query
* (limit 0, calculateTotal) per tab, batched in a single request. Each
* entry's `filter` is the tab's resolved FilterCondition/FilterOperator
* (null = no extra condition, i.e. all unread in the mailbox).
* (limit 0, calculateTotal) per tab, batched into as few requests as the
* server's method-call ceiling allows. Each entry's `filter` is the tab's
* resolved FilterCondition/FilterOperator (null = no extra condition, i.e.
* all unread in the mailbox).
*/
async getCategoryUnreadCounts(
mailboxId: string,
@@ -1324,31 +1332,34 @@ export class JMAPClient implements IJMAPClient {
): Promise<Record<string, number>> {
if (tabs.length === 0) return {};
const targetAccountId = accountId || this.accountId;
try {
const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => {
const conditions: Record<string, unknown>[] = [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
];
if (tab.filter) conditions.push(tab.filter);
return ["Email/query", {
accountId: targetAccountId,
filter: { operator: "AND", conditions },
limit: 0,
calculateTotal: true,
}, `tab_${i}`];
});
const result: Record<string, number> = {};
const response = await this.request(methodCalls);
const result: Record<string, number> = {};
for (let i = 0; i < tabs.length; i++) {
result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
for (const batch of batched(tabs, this.getMaxCallsInRequest())) {
try {
const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => {
const conditions: Record<string, unknown>[] = [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
];
if (tab.filter) conditions.push(tab.filter);
return ["Email/query", {
accountId: targetAccountId,
filter: { operator: "AND", conditions },
limit: 0,
calculateTotal: true,
}, `tab_${i}`];
});
const response = await this.request(methodCalls);
for (let i = 0; i < batch.length; i++) {
result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
}
} catch (error) {
console.error('Failed to get category tab counts:', error);
}
return result;
} catch (error) {
console.error('Failed to get category tab counts:', error);
return {};
}
return result;
}
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
@@ -1464,10 +1475,12 @@ export class JMAPClient implements IJMAPClient {
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
}
}
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
@@ -1529,10 +1542,12 @@ export class JMAPClient implements IJMAPClient {
*/
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
}
}
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
@@ -1562,9 +1577,7 @@ export class JMAPClient implements IJMAPClient {
if (allIds.length === 0) return 0;
// Batch update: remove old keyword, add new keyword using per-property patches
const updateBatchSize = 50;
for (let i = 0; i < allIds.length; i += updateBatchSize) {
const batch = allIds.slice(i, i + updateBatchSize);
for (const batch of batched(allIds, this.getMaxObjectsInSet())) {
const update: Record<string, Record<string, boolean | null>> = {};
for (const id of batch) {
update[id] = {
@@ -1608,12 +1621,14 @@ export class JMAPClient implements IJMAPClient {
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
await this.request([
["Email/set", {
accountId: accountId || this.accountId,
destroy: emailIds,
}, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
await this.request([
["Email/set", {
accountId: accountId || this.accountId,
destroy: batch,
}, "0"],
]);
}
}
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
@@ -1624,10 +1639,12 @@ export class JMAPClient implements IJMAPClient {
if (markAsRead) patch["keywords/$seen"] = true;
return patch;
};
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
const updates = Object.fromEntries(batch.map(id => [id, buildPatch()]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
}
}
async batchArchiveEmails(
@@ -1705,35 +1722,59 @@ export class JMAPClient implements IJMAPClient {
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
// Creation ids are scoped to the request that introduced them (RFC 8620
// §3.3), so "#<cid>" only resolves in the request carrying the Mailbox/set:
// the folders are created alongside the first batch of messages, and the
// ids they were assigned are substituted into every later batch.
const updateBatches = batched(Object.entries(updates), this.getMaxObjectsInSet());
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
let createdIdFor: Record<string, string> = {};
const response = await this.request(methodCalls);
for (let i = 0; i < updateBatches.length; i++) {
const batch: Array<[string, { mailboxIds: Record<string, true> }]> = i === 0
? updateBatches[i]
: updateBatches[i].map(([emailId, patch]) => {
const [destId] = Object.keys(patch.mailboxIds);
const resolved = createdIdFor[destId];
return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record<string, true> } : patch];
});
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' ')}`);
const methodCalls: JMAPMethodCall[] = [];
const withCreates = hasCreates && i === 0;
if (withCreates) {
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
}
}
methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]);
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
const response = await this.request(methodCalls);
if (withCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' ')}`);
}
const created = (mailboxResult?.created || {}) as Record<string, { id?: string }>;
createdIdFor = Object.fromEntries(
Object.entries(created)
.filter(([, mailbox]) => !!mailbox?.id)
.map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]),
);
}
const emailIdx = withCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
}
}
}
@@ -1758,15 +1799,19 @@ export class JMAPClient implements IJMAPClient {
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const batchSize = Math.min(500, this.getMaxObjectsInSet());
let totalDestroyed = 0;
let hasMore = true;
while (hasMore) {
// Destroy in batches until the mailbox is empty. Never gate the loop on
// Email/query's `total`: it is only guaranteed when `calculateTotal` is
// requested, and Stalwart omits it otherwise, which used to stop the loop
// after the first batch and leave folders with >500 emails mostly intact.
while (true) {
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: { inMailbox: mailboxId },
limit: 500,
limit: batchSize,
}, "0"],
["Email/set", {
accountId: targetAccountId,
@@ -1776,10 +1821,16 @@ export class JMAPClient implements IJMAPClient {
const queryResult = response.methodResponses?.[0]?.[1];
const setResult = response.methodResponses?.[1]?.[1];
const found: string[] = queryResult?.ids || [];
const destroyed = setResult?.destroyed?.length || 0;
totalDestroyed += destroyed;
hasMore = destroyed > 0 && (queryResult?.total || 0) > destroyed;
// Nothing left, or the server refused everything in this batch (missing
// permission, immutable mail) — stop instead of looping forever on the
// same ids.
if (found.length === 0 || destroyed === 0) break;
// A short page means we just handled the tail of the mailbox.
if (found.length < batchSize) break;
}
return totalDestroyed;
@@ -1787,6 +1838,7 @@ export class JMAPClient implements IJMAPClient {
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const pageSize = Math.min(500, this.getMaxObjectsInSet());
let totalMarked = 0;
let hasMore = true;
@@ -1801,7 +1853,7 @@ export class JMAPClient implements IJMAPClient {
{ notKeyword: "$seen" },
],
},
limit: 500,
limit: pageSize,
}, "0"],
]);
@@ -1817,7 +1869,7 @@ export class JMAPClient implements IJMAPClient {
]);
totalMarked += ids.length;
hasMore = ids.length === 500;
hasMore = ids.length === pageSize;
}
return totalMarked;
@@ -1826,6 +1878,7 @@ export class JMAPClient implements IJMAPClient {
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const excludeSet = new Set(excludeMailboxIds);
const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet());
let totalMarked = 0;
let hasMore = true;
let position = 0;
@@ -1835,7 +1888,7 @@ export class JMAPClient implements IJMAPClient {
["Email/query", {
accountId: targetAccountId,
filter: { notKeyword: "$seen" },
limit: 500,
limit: pageSize,
position,
}, "0"],
["Email/get", {
@@ -1871,7 +1924,7 @@ export class JMAPClient implements IJMAPClient {
totalMarked += targetIds.length;
}
hasMore = ids.length === 500;
hasMore = ids.length === pageSize;
position += ids.length;
}
@@ -2183,14 +2236,19 @@ export class JMAPClient implements IJMAPClient {
if (threadIds.length === 0) return [];
try {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"],
]);
const threads: Thread[] = [];
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
return (response.methodResponses[0][1].list || []) as Thread[];
for (const batchIds of batched(threadIds, this.getMaxObjectsInGet())) {
const response = await this.request([
["Thread/get", { accountId: targetAccountId, ids: batchIds }, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
threads.push(...((response.methodResponses[0][1].list || []) as Thread[]));
}
}
return [];
return threads;
} catch (error) {
console.error('Failed to get threads:', error);
return [];
@@ -2205,26 +2263,32 @@ export class JMAPClient implements IJMAPClient {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: thread.emailIds,
properties: [
...EMAIL_LIST_PROPERTIES,
"textBody", "htmlBody", "bodyValues",
"attachments", "blobId", "sentAt", "bcc", "replyTo",
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, "0"],
]);
const emails: Email[] = [];
if (response.methodResponses?.[0]?.[0] === "Email/get") {
const emails = response.methodResponses[0][1].list || [];
for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: batchIds,
properties: [
...EMAIL_LIST_PROPERTIES,
"textBody", "htmlBody", "bodyValues",
"attachments", "blobId", "sentAt", "bcc", "replyTo",
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Email/get") {
emails.push(...(response.methodResponses[0][1].list || []));
}
}
if (emails.length > 0) {
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
@@ -3596,6 +3660,11 @@ export class JMAPClient implements IJMAPClient {
return coreCapability?.maxObjectsInGet || 500;
}
getMaxObjectsInSet(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInSet?: number } | undefined;
return coreCapability?.maxObjectsInSet || 500;
}
getMaxDelayedSend(accountId?: string): number {
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
@@ -5050,38 +5119,41 @@ export class JMAPClient implements IJMAPClient {
const accountId = targetAccountId || this.getCalendarsAccountId();
// Build the create map: { "new-0": event0, "new-1": event1, ... }
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
createMap[`new-${i}`] = clean;
}
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
// Never emit iMIP scheduling messages when importing. Imported events often
// carry an organizer/participants where the current user is the organizer;
// without this, Stalwart tries to send invitation emails to every attendee
// synchronously during CalendarEvent/set, which is both wrong (importing a
// calendar should not spam invites) and can block the request indefinitely,
// leaving the import spinner spinning forever (#411).
const response = await this.request([
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
], this.calendarUsing());
const createdIds: string[] = [];
const failed: string[] = [];
const indexed = events.map((event, index) => ({ event, index }));
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
for (let i = 0; i < events.length; i++) {
const key = `new-${i}`;
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
for (const batch of batched(indexed, this.getMaxObjectsInSet())) {
// Build the create map: { "new-0": event0, "new-1": event1, ... }
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (const { event, index } of batch) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
createMap[`new-${index}`] = clean;
}
// Never emit iMIP scheduling messages when importing. Imported events often
// carry an organizer/participants where the current user is the organizer;
// without this, Stalwart tries to send invitation emails to every attendee
// synchronously during CalendarEvent/set, which is both wrong (importing a
// calendar should not spam invites) and can block the request indefinitely,
// leaving the import spinner spinning forever (#411).
const response = await this.request([
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
for (const { index } of batch) {
const key = `new-${index}`;
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
}
}
}
}
@@ -5090,21 +5162,24 @@ export class JMAPClient implements IJMAPClient {
return { created: [], failed };
}
// Fetch all created events in a single CalendarEvent/get
// Fetch the created events back for their server-assigned properties
const refetchTimeZone = getUserTimeZone();
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: createdIds,
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
}, "0"]
], this.calendarUsing());
const createdEvents: CalendarEvent[] = [];
let createdEvents: CalendarEvent[] = [];
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) {
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: batchIds,
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)));
}
}
debug.log('calendar', 'CalendarEvent/batchCreate result', {
@@ -5255,17 +5330,19 @@ export class JMAPClient implements IJMAPClient {
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
], this.calendarUsing());
const destroyed: string[] = [];
const notDestroyed: string[] = [];
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.destroyed) destroyed.push(...result.destroyed);
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
for (const batch of batched(eventIds, this.getMaxObjectsInSet())) {
const response = await this.request([
["CalendarEvent/set", { accountId, destroy: batch }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.destroyed) destroyed.push(...result.destroyed);
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
}
}
return { destroyed, notDestroyed };
@@ -5524,7 +5601,7 @@ export class JMAPClient implements IJMAPClient {
}
private static FILE_NODE_PROPERTIES = [
"id", "parentId", "name", "type", "blobId", "size", "created", "updated",
"id", "parentId", "name", "type", "blobId", "size", "created", "modified",
// Stalwart omits shareWith/myRights from FileNode/get unless requested
// explicitly, so the share dialog and indicators can't see existing
// shares without naming them here (same as CALENDAR_PROPERTIES).
@@ -5778,62 +5855,69 @@ export class JMAPClient implements IJMAPClient {
* throws for per-node failures (only for a whole-method error).
*/
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
const ids = Object.keys(updates);
if (ids.length === 0) return { updated: [], notUpdated: {} };
const entries = Object.entries(updates);
if (entries.length === 0) return { updated: [], notUpdated: {} };
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", { accountId, update: updates }, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
const updatedMap: Record<string, unknown> = result[1].updated || {};
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
const updated: string[] = [];
const notUpdated: Record<string, string> = {};
for (const id of Object.keys(notUpdatedMap)) {
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
for (const batch of batched(entries, this.getMaxObjectsInSet())) {
const response = await this.request(
[["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
const updatedMap: Record<string, unknown> = result[1].updated || {};
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
for (const id of Object.keys(notUpdatedMap)) {
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
}
// Servers may omit the `updated` map; treat anything not rejected as updated.
updated.push(...(Object.keys(updatedMap).length > 0
? Object.keys(updatedMap)
: batch.map(([id]) => id).filter(id => !(id in notUpdated))));
}
// Servers may omit the `updated` map; treat anything not rejected as updated.
const updated = Object.keys(updatedMap).length > 0
? Object.keys(updatedMap)
: ids.filter(id => !(id in notUpdated));
return { updated, notUpdated };
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const accountId = this.getFilesAccountId();
const destroyed: string[] = [];
const response = await this.request(
[["FileNode/set", {
accountId,
destroy: ids,
onDestroyRemoveChildren: true,
}, "fns0"]],
this.fileUsing(),
);
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
const response = await this.request(
[["FileNode/set", {
accountId,
destroy: batch,
onDestroyRemoveChildren: true,
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
}
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
const notDestroyedIds = Object.keys(notDestroyedMap);
if (notDestroyedIds.length > 0) {
const firstError = notDestroyedMap[notDestroyedIds[0]];
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
}
destroyed.push(...(result[1].destroyed || []));
}
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
const notDestroyedIds = Object.keys(notDestroyedMap);
if (notDestroyedIds.length > 0) {
const firstError = notDestroyedMap[notDestroyedIds[0]];
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
}
return {
destroyed: result[1].destroyed || [],
notDestroyed: [],
};
return { destroyed, notDestroyed: [] };
}
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
@@ -6394,6 +6478,43 @@ export class JMAPClient implements IJMAPClient {
* a shared mailbox owned by another user). When omitted, falls back to the
* client's own primary account.
*/
async copyEmailAcrossAccounts(
emailId: string,
fromAccountId: string,
toAccountId: string,
destMailboxId: string,
): Promise<string> {
// Email/copy drops keywords unless the create sets them, so carry the
// source's over — otherwise the moved message shows up as unread.
const srcResp = await this.request([
["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"],
]);
const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {};
// onSuccessDestroyOriginal is the spec-correct way to remove the source, but
// Stalwart currently destroys the copy's create-id instead of the source id,
// so the original is left behind — a duplicate on every cross-account move.
// Reported upstream (support.stalw.art #1150); this self-heals once fixed.
const response = await this.request([
["Email/copy", {
fromAccountId,
accountId: toAccountId,
create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } },
onSuccessDestroyOriginal: true,
}, "0"],
]);
const res = response.methodResponses?.[0]?.[1];
const err = res?.notCreated?.c;
if (err) {
throw new Error(err.description || err.type || "Failed to copy email across accounts");
}
const id = res?.created?.c?.id;
if (!id) {
throw new Error("Email/copy succeeded but no ID returned");
}
return id;
}
async importRawEmail(
blob: Blob,
mailboxIds: Record<string, boolean>,
+26
View File
@@ -0,0 +1,26 @@
/**
* A JMAP session advertises hard ceilings on what one request may carry: how
* many method calls it holds (`maxCallsInRequest`) and how many objects a
* single /get or /set may touch (`maxObjectsInGet`, `maxObjectsInSet`). Going
* over any of them fails the *whole* request, not the surplus, so a batch built
* from a list the user controls - tags, category tabs, a multi-select, an
* import - is split against the advertised limit before it is sent.
*
* Stalwart defaults to 16 method calls and 500 objects, so the ceilings are low
* enough to reach with ordinary use: nine tags is already 18 calls.
*/
/** Split `items` into consecutive batches of at most `size` entries. */
export function batched<T>(items: T[], size: number): T[][] {
const step = Math.max(1, Math.floor(size));
const result: T[][] = [];
for (let i = 0; i < items.length; i += step) {
result.push(items.slice(i, i + step));
}
return result;
}
/** How many items fit in one request when each item costs `callsPerItem` method calls. */
export function itemsPerRequest(maxCalls: number, callsPerItem: number): number {
return Math.max(1, Math.floor(maxCalls / callsPerItem));
}
+5 -1
View File
@@ -835,7 +835,11 @@ export interface FileNode {
blobId: string | null;
size: number;
created: string;
updated: string;
// Last content/metadata change, server-maintained. The property is named
// `modified` in draft-ietf-jmap-filenode and in Stalwart - there is no
// `updated` on a FileNode. Asking for the wrong name silently yields
// undefined, which made the UI show the creation date forever (#700).
modified: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// filenode capability and the properties are explicitly requested. A node is
// shared-out when `shareWith` has entries; `myRights` describes what the
+83
View File
@@ -0,0 +1,83 @@
/**
* Naming a tag on screen.
*
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
* one is simply its own name, so nothing here asks the caller which kind it
* has. `keywordRenderings` additionally offers progressively shorter forms for
* a name with nowhere to fit, which `useShortenedText` measures against the
* room actually available.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
/** Stands in for one level left out of a name. */
export const KEYWORD_SHORTENED_LEVEL = "..";
/** Stands in for a run of more than one level left out of a name. */
export const KEYWORD_SHORTENED_RUN = "...";
/**
* The display name of a tag, one entry per level, outermost first. A tag with
* one level yields a single entry, so callers need not care either way.
*
* `nested` is the user's setting. With nesting off a slash carries no meaning,
* so the id is one opaque token and the tag is named by its own label - nobody
* who left the setting alone should find their tags rewritten because an id
* happens to contain a slash, which can outlast turning nesting off, or arrive
* through settings sync or another client.
*
* With nesting on, each level resolves to that tag's display name, falling back
* to the raw level of the id when it has no definition - the settings list only
* describes the tags this client knows about. Levels stay separate entries
* because a display name may itself contain a slash, which is part of that one
* name rather than a level of its own.
*/
export function formatKeywordLabels(
id: string,
keywords: KeywordDefinition[],
nested: boolean,
): string[] {
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
if (!nested) return [label(id) ?? id];
const levels = keywordLevels(id);
return levels.map((level, index) =>
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
);
}
/**
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
* otherwise. The general way to name a tag on screen.
*/
export function formatKeyword(
id: string,
keywords: KeywordDefinition[],
nested: boolean,
): string {
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
}
/**
* Every way a name can be written, longest first: in full, then with an ever
* longer run of intermediate levels replaced by `..`, collapsing to a single
* `...` as soon as that run covers more than one level.
*
* The outermost and innermost levels always survive - between them they say
* which branch a tag belongs to and which tag it is, which is exactly what a
* trailing ellipsis destroys. A rendering that would not actually come out
* shorter than the one before it (levels named `it`, say) is dropped, so
* walking the list never makes the text grow.
*/
export function keywordRenderings(levels: string[]): string[] {
const renderings = [levels.join(KEYWORD_SEPARATOR)];
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
.join(KEYWORD_SEPARATOR);
if (rendering.length < renderings[renderings.length - 1].length) {
renderings.push(rendering);
}
}
return renderings;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Tag nesting.
*
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
* is a slug derived from the display name. Nesting reuses that single id: the
* levels are joined with a forward slash, so `$label:work/clients` is the child
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
* the source of truth for tag membership and existing lookups by keyword keep
* working.
*
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
* composed id has to stay within.
*
* Turning any of this into text for the screen lives in `keyword-format`.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_PREFIX } from "./thread-utils";
/** Separates parent from child inside a tag id. */
export const KEYWORD_SEPARATOR = "/";
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
export const MAX_KEYWORD_LENGTH = 255;
/** What is left for the id once the `$label:` prefix is spent. */
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
/** A tag definition placed in the hierarchy its id describes. */
export interface KeywordNode extends KeywordDefinition {
children: KeywordNode[];
depth: number;
}
/**
* Reduces a display name to one level of an id: lowercase, and everything
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
* a slash typed into the name is a literal part of that name, not a level.
* The only slug function for tag ids; keep it the only one.
*/
export function normalizeKeywordLevel(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
export function composeKeywordId(parentId: string | null, name: string): string {
const level = normalizeKeywordLevel(name);
if (!parentId || !level) return level;
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
}
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
export function keywordLevels(id: string): string[] {
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
}
/** The id of the tag one level up, or null for a top-level tag. */
export function getParentKeywordId(id: string): string | null {
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
return index === -1 ? null : id.slice(0, index);
}
/** True when `candidateId` sits anywhere below `ancestorId`. */
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
}
/** True when any defined tag sits below `id`. */
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
}
/**
* Arranges tag definitions into the tree their ids describe, preserving the
* user's manual order within each level.
*
* A tag whose direct parent is not defined stays at the root rather than being
* hidden or grafted onto a grandparent; callers name such a root in full so the
* missing level is still visible.
*/
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
const nodes = new Map<string, KeywordNode>();
for (const keyword of keywords) {
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
}
const roots: KeywordNode[] = [];
for (const keyword of keywords) {
const node = nodes.get(keyword.id)!;
const parentId = getParentKeywordId(keyword.id);
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const setDepth = (node: KeywordNode, depth: number) => {
node.depth = depth;
node.children.forEach((child) => setDepth(child, depth + 1));
};
roots.forEach((root) => setDepth(root, 0));
return roots;
}
/**
* Prunes a tag tree down to the nodes worth showing.
*
* A node survives when the predicate accepts it or when any of its descendants
* survives, so hiding a parent never strands the children below it. Depths are
* left untouched: a kept node keeps the indentation of its original level even
* when the level above it is only there to carry it.
*/
export function filterKeywordTree(
nodes: KeywordNode[],
isVisible: (node: KeywordNode) => boolean,
): KeywordNode[] {
const kept: KeywordNode[] = [];
for (const node of nodes) {
const children = filterKeywordTree(node.children, isVisible);
if (children.length > 0 || isVisible(node)) {
kept.push({ ...node, children });
}
}
return kept;
}
/** Total number of nodes in a tag tree, at every level. */
export function countKeywordNodes(nodes: KeywordNode[]): number {
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
}
+15 -16
View File
@@ -470,21 +470,20 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
// This salt acts as a constant context identifier for key derivation.
// While hardcoded, security is maintained because the WebAuthn PRF extension
// mixes this salt with the device's unique, hardware-bound private key.
// Changing this string will result in a completely different derived secret.
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
/**
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
* This secret is typically used as a local master encryption key.
*/
async function doGetOrCreatePRF(
masterCredentialIdBytes: number[] | undefined,
pluginId: string,
name?: string,
displayName?: string
displayName?: string,
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
// To avoid a privileged plugin to access secret created from another privileged plugin,
// we add the pluginID from manifest in salt.
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1" + pluginId)
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
@@ -496,18 +495,18 @@ async function doGetOrCreatePRF(
challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [{ type: "public-key", id: credentialId }],
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
extensions: { prf: { eval: { first: PRF_SALT } } } as any
extensions: { prf: { eval: { first: PRF_SALT } } }
}
}) as PublicKeyCredential;
// Extract the derived symmetric key from the authenticator's output
const outputs = assertion.getClientExtensionResults();
const prfSecret = (outputs as any).prf?.results?.first;
const prfSecret = (outputs).prf?.results?.first;
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
return {
credentialId: masterCredentialIdBytes,
prfSecret: Array.from(new Uint8Array(prfSecret))
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
};
}
@@ -532,14 +531,14 @@ async function doGetOrCreatePRF(
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
userVerification: "required"
},
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
extensions: { prf: {} } // Request PRF extension support from the authenticator
}
}) as PublicKeyCredential;
const outputs = credential.getClientExtensionResults();
// Ensure the authenticator successfully enabled and supports the PRF extension
const isPrfEnabled = (outputs as any).prf?.enabled;
const isPrfEnabled = (outputs).prf?.enabled;
if (!isPrfEnabled) {
return 'The authenticator does not support or has rejected the PRF extension.';
}
@@ -556,20 +555,20 @@ async function doGetOrCreatePRF(
userVerification: "required",
extensions: {
prf: { eval: { first: PRF_SALT } }
} as any
}
}
}) as PublicKeyCredential;
const assertionOutputs = assertion.getClientExtensionResults();
const prfSecret = (assertionOutputs as any).prf?.results?.first;
const prfSecret = (assertionOutputs).prf?.results?.first;
if (!prfSecret) {
return 'Cannot get PRF secret from existing credential.';
}
return {
credentialId: Array.from(new Uint8Array(credential.rawId)),
prfSecret: Array.from(new Uint8Array(prfSecret))
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
};
}
@@ -778,7 +777,7 @@ export async function dispatchApiCall(
);
case 'upfiles.get' : return getFile(args[0] as string);
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string, args[2] as string | undefined, args[3] as string | undefined);
case 'contact.get': return doContactGet(args[0] as string);
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
+1 -1
View File
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
settings: { ...manifest.settings },
},
webauthn: {
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0)
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
+108
View File
@@ -0,0 +1,108 @@
export interface ReplyAddress {
email?: string;
name?: string;
}
export interface ReplySource {
from?: ReplyAddress[];
/** Addresses from the original message's Reply-To header. */
replyToAddresses?: ReplyAddress[];
to?: ReplyAddress[];
cc?: ReplyAddress[];
}
export interface ReplyRecipientsResult {
to: ReplyAddress[];
cc: ReplyAddress[];
}
function normalize(email: string): string {
return email.trim().toLowerCase();
}
function normalizeBase(email: string): string {
const normalized = normalize(email);
const at = normalized.indexOf('@');
if (at <= 0) return normalized;
const local = normalized.slice(0, at);
const domain = normalized.slice(at + 1);
const plus = local.indexOf('+');
return `${plus >= 0 ? local.slice(0, plus) : local}@${domain}`;
}
/**
* Does `email` belong to the user? Matches exactly first, then with `+tag`
* sub-addressing stripped (info+news@ is still info@).
*/
function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean {
if (!email?.trim()) return false;
const exact = normalize(email);
if (ownEmails.some((own) => normalize(own) === exact)) return true;
const base = normalizeBase(email);
return ownEmails.some((own) => normalizeBase(own) === base);
}
/**
* Is this a message the user themself sent? True when the From address is one
* of their own identities - the case that shows up when browsing a thread and
* replying to your own last message.
*/
export function isSelfSent(source: ReplySource | undefined, ownEmails: string[]): boolean {
return isOwnAddress(source?.from?.[0]?.email, ownEmails);
}
/**
* Work out the To/Cc a reply should open with.
*
* Normal case: reply goes to the Reply-To header if the original carried one,
* else to From (RFC 5322). Reply-all adds the other original recipients,
* minus the user's own addresses.
*
* Self-sent case (#703): replying to your own message inside a thread must
* continue the conversation, not mail yourself. Gmail and Thunderbird address
* the reply to the message's original recipients instead, so that's what we do
* - the original To for reply, plus the original Cc for reply-all. Those
* addresses were the user's own choice, so they're kept verbatim (no self-
* filtering) and the Reply-To header is ignored, since answering your own
* Reply-To would land the mail back in your inbox again.
*
* A self-sent message with no visible recipients (Bcc-only) has nothing to
* continue to, so it falls back to the normal behaviour.
*/
export function buildReplyRecipients(
source: ReplySource | undefined,
mode: 'reply' | 'replyAll',
ownEmails: string[],
): ReplyRecipientsResult {
if (!source) return { to: [], cc: [] };
const withEmail = (list: ReplyAddress[] | undefined) => (list ?? []).filter((r) => Boolean(r.email));
if (isSelfSent(source, ownEmails)) {
const originalTo = withEmail(source.to);
if (originalTo.length > 0) {
return {
to: originalTo,
cc: mode === 'replyAll' ? withEmail(source.cc) : [],
};
}
}
const replyTarget = withEmail(source.replyToAddresses).length
? withEmail(source.replyToAddresses)
: (source.from?.[0]?.email ? [source.from[0]] : []);
if (mode === 'reply') {
return { to: replyTarget, cc: [] };
}
const others = (list: ReplyAddress[] | undefined) =>
withEmail(list).filter((r) => !isOwnAddress(r.email, ownEmails));
return {
to: [...replyTarget, ...others(source.to)],
cc: others(source.cc),
};
}
+30 -12
View File
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:";
/**
* Gets all active label/color tag IDs from email keywords.
* Gets every tag id set on a message.
* Reads both the current $label: prefix and the legacy $color: prefix.
* A tag written under both spellings is one tag, so it is returned once.
*/
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return [];
const tags: string[] = [];
const tags = new Set<string>();
for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
tags.push(
tags.add(
key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length)
);
}
}
return tags;
return [...tags];
}
/**
* Gets label/color tag from email keywords (if any).
* Gets the first tag id set on a message, if any.
* Reads both the current $label: prefix and the legacy $color: prefix.
* @deprecated Use getEmailColorTags for multi-tag support.
* @deprecated Use getEmailTagIds for multi-tag support.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords);
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailTagIds(keywords);
return tags.length > 0 ? tags[0] : null;
}
/**
* Checks if a thread has any color tag (returns first found).
* The first tag id found anywhere in a thread, if any.
*/
export function getThreadColorTag(emails: Email[]): string | null {
export function getThreadTagId(emails: Email[]): string | null {
for (const email of emails) {
const color = getEmailColorTag(email.keywords);
const color = getEmailTagId(email.keywords);
if (color) return color;
}
return null;
}
/**
* Every tag anywhere in a thread, deduplicated.
*
* A collapsed thread row stands in for all its messages, so it has to account
* for all their tags - showing only the first message's would hide the rest
* with nothing to indicate they exist.
*/
export function getThreadTagIds(emails: Email[]): string[] {
const tags = new Set<string>();
for (const email of emails) {
for (const tag of getEmailTagIds(email.keywords)) {
tags.add(tag);
}
}
return [...tags];
}
+8 -2
View File
@@ -844,7 +844,9 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
// An organization name identifies the card just as well as a personal name.
const hasOrg = !!Object.values(card.organizations || {})[0]?.name;
if (!hasName && !hasEmail && !hasOrg && card.kind !== "group") return null;
return card;
}
@@ -882,7 +884,11 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = findKind("generation", "suffix");
const additional = findKind("given2", "additional", "middle");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
// FN is mandatory in vCard, so fall back to the organization name for org cards.
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ")
|| contact.name?.full
|| Object.values(contact.organizations || {})[0]?.name
|| "";
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "إعادة تعيين",
"demo_tour": "جولة",
"tags": "الوسوم",
"show_all_tags": "إظهار الكل ({count})",
"show_fewer_tags": "إظهار أقل",
"folders": "المجلدات",
"shared": "مشترك",
"mail": "البريد",
@@ -298,6 +300,7 @@
"print": "طباعة",
"view_source": "عرض المصدر",
"export_email": "تصدير كملف .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "استيراد ملف .eml أو .zip",
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
"email_source": "مصدر الرسالة",
@@ -324,13 +327,15 @@
"view_contact": "عرض جهة الاتصال",
"message_details": "تفاصيل الرسالة",
"more_reply_options": "خيارات رد إضافية",
"set_color": "تعيين وسم",
"set_tag": "تعيين وسم",
"tag": "وسم",
"more_actions": "المزيد من الإجراءات",
"previous": "السابق",
"next": "التالي",
"move_to": "نقل إلى...",
"remove_color": "إزالة الوسم",
"remove_tag": "إزالة الوسم",
"tag_filter_placeholder": "تصفية الوسوم",
"tag_no_matches": "لا توجد وسوم مطابقة",
"more_count": "+{count} أخرى",
"characters_count": "{count} حرفًا",
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
@@ -424,17 +429,6 @@
"message_id": "معرّف الرسالة",
"list_info": "معلومات القائمة"
},
"color_tag": {
"title": "وسم لوني",
"red": "أحمر",
"orange": "برتقالي",
"yellow": "أصفر",
"green": "أخضر",
"blue": "أزرق",
"purple": "بنفسجي",
"pink": "وردي",
"none": "بلا"
},
"tooltips": {
"reply": "رد (r)",
"reply_all": "الرد على الجميع (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "الاسم المعروض",
"autocomplete_search_server": "البحث في الخادم",
"autocomplete_searching": "جارٍ البحث...",
"toolbar": {
"bold": "غامق",
"italic": "مائل",
"underline": "تسطير",
"strikethrough": "يتوسطه خط",
"text_color": "لون النص",
"remove_color": "إزالة اللون",
"heading_1": "عنوان 1",
"heading_2": "عنوان 2",
"bullet_list": "قائمة نقطية",
"ordered_list": "قائمة مرقمة",
"quote": "اقتباس",
"code_block": "كتلة برمجية",
"align_left": "محاذاة لليسار",
"align_center": "توسيط",
"align_right": "محاذاة لليمين",
"text_direction": "اتجاه النص (RTL/LTR)",
"link": "رابط",
"table": "جدول",
"clear_formatting": "مسح التنسيق",
"undo": "تراجع",
"redo": "إعادة",
"add_row_above": "إضافة صف بالأعلى",
"add_row_below": "إضافة صف بالأسفل",
"add_column_before": "إضافة عمود قبل",
"add_column_after": "إضافة عمود بعد",
"delete_row": "حذف الصف",
"delete_column": "حذف العمود",
"toggle_header_row": "تبديل صف الرأس",
"delete_table": "حذف الجدول",
"pick_size": "اختيار الحجم"
},
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "وسوم البريد",
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
"add_keyword": "إضافة وسم",
"reset_defaults": "إعادة التعيين للافتراضي",
"label_field": "الاسم المعروض",
"label_placeholder": "مثال: عمل، شخصي، عاجل",
"id_field": "معرّف الوسم",
@@ -999,7 +1024,22 @@
"add": "إضافة",
"cancel": "إلغاء",
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
"migration_error": "فشل تحديث الوسم على الرسائل الحالية"
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
"nesting": {
"label": "وسوم متداخلة",
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
},
"parent_field": "الوسم الأصل",
"no_parent": "بدون وسم أصل",
"too_long": "مسار الوسم طويل جدًا ({max} حرفًا على الأكثر)",
"has_children_locked": "توجد وسوم أخرى متداخلة تحت هذا الوسم، لذا فإن اسمه ووسمه الأصل مقفلان. انقلها أو احذفها أولًا.",
"has_children_delete": "احذف أولًا الوسوم المتداخلة تحت هذا الوسم",
"visibility_field": "الظهور في الشريط الجانبي",
"visibility": {
"show": "إظهار",
"unread": "إظهار عند وجود غير مقروء",
"hide": "إخفاء"
}
},
"notifications": {
"test_sound": "اختبار صوت الإشعار",
@@ -2001,8 +2041,7 @@
"delete": "حذف",
"mark_as_spam": "الإبلاغ عن بريد مزعج",
"not_spam": "ليس مزعجًا",
"color_tag": "وسم",
"remove_color": "إزالة الوسم",
"tag": "وسم",
"items_selected": "{count} رسالة محددة",
"edit_draft": "تعديل المسودة",
"cancel_scheduled_send": "إلغاء الإرسال",
@@ -2337,6 +2376,9 @@
"section_address_book": "الدليل",
"select_address_book": "اختر دليلًا...",
"section_identity": "الاسم والهوية",
"contact_type": "نوع جهة الاتصال",
"type_person": "شخص",
"type_organization": "المؤسسة",
"section_work": "العمل والمؤسسة",
"prefix": "اللقب",
"prefix_placeholder": "د.، أ.، السيدة",
@@ -2421,7 +2463,7 @@
"cancel": "إلغاء",
"creating": "جارٍ الإنشاء...",
"updating": "جارٍ التحديث...",
"name_required": "يلزم إدخال الاسم الأول أو اسم العائلة على الأقل",
"name_required": "أدخل اسمًا أول أو اسم عائلة أو مؤسسة",
"email_invalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
"email_error_inline": "تنسيق البريد الإلكتروني غير صالح",
"save_failed": "فشل حفظ جهة الاتصال",
File diff suppressed because it is too large Load Diff
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetovat",
"demo_tour": "Průvodce",
"tags": "Štítky",
"show_all_tags": "Zobrazit vše ({count})",
"show_fewer_tags": "Zobrazit méně",
"folders": "Složky",
"shared": "Sdílené",
"mail": "Pošta",
@@ -298,6 +300,7 @@
"print": "Tisk",
"view_source": "Zobrazit zdrojový kód",
"export_email": "Exportovat jako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importovat .eml nebo .zip",
"keyboard_shortcuts": "Klávesové zkratky (?)",
"email_source": "Zdrojový kód zprávy",
@@ -324,13 +327,15 @@
"view_contact": "Zobrazit kontakt",
"message_details": "Podrobnosti zprávy",
"more_reply_options": "Další možnosti odpovědi",
"set_color": "Nastavit štítek",
"set_tag": "Nastavit štítek",
"tag": "Štítek",
"more_actions": "Další akce",
"previous": "Předchozí",
"next": "Další",
"move_to": "Přesunout do...",
"remove_color": "Odebrat štítek",
"remove_tag": "Odebrat štítek",
"tag_filter_placeholder": "Filtrovat štítky",
"tag_no_matches": "Žádné odpovídající štítky",
"more_count": "+{count} dalších",
"characters_count": "{count} znaků",
"quick_reply_placeholder": "Napsat rychlou odpověď...",
@@ -399,17 +404,6 @@
"message_id": "ID zprávy",
"list_info": "Informace o konferenci"
},
"color_tag": {
"title": "Barevný štítek",
"red": "Červený",
"orange": "Oranžový",
"yellow": "Žlutý",
"green": "Zelený",
"blue": "Modrý",
"purple": "Fialový",
"pink": "Růžový",
"none": "Žádný"
},
"tooltips": {
"reply": "Odpovědět (r)",
"reply_all": "Odpovědět všem (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Zobrazované jméno",
"autocomplete_search_server": "Hledat na serveru",
"autocomplete_searching": "Hledání...",
"toolbar": {
"bold": "Tučné",
"italic": "Kurzíva",
"underline": "Podtržené",
"strikethrough": "Přeškrtnuté",
"text_color": "Barva textu",
"remove_color": "Odebrat barvu",
"heading_1": "Nadpis 1",
"heading_2": "Nadpis 2",
"bullet_list": "Odrážkový seznam",
"ordered_list": "Číslovaný seznam",
"quote": "Citace",
"code_block": "Blok kódu",
"align_left": "Zarovnat vlevo",
"align_center": "Na střed",
"align_right": "Zarovnat vpravo",
"text_direction": "Směr textu (RTL/LTR)",
"link": "Odkaz",
"table": "Tabulka",
"clear_formatting": "Vymazat formátování",
"undo": "Zpět",
"redo": "Znovu",
"add_row_above": "Přidat řádek nad",
"add_row_below": "Přidat řádek pod",
"add_column_before": "Přidat sloupec před",
"add_column_after": "Přidat sloupec za",
"delete_row": "Odstranit řádek",
"delete_column": "Odstranit sloupec",
"toggle_header_row": "Přepnout řádek záhlaví",
"delete_table": "Odstranit tabulku",
"pick_size": "Vybrat velikost"
},
"send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "E-mailové štítky",
"description": "Definujte štítky pro organizaci e-mailů pomocí barev. Ukládají se jako klíčová slova JMAP na serveru.",
"add_keyword": "Přidat štítek",
"reset_defaults": "Obnovit výchozí",
"label_field": "Zobrazovaný název",
"label_placeholder": "např. Práce, Osobní, Naliehavé",
"id_field": "ID štítku",
@@ -996,7 +1021,22 @@
"add": "Přidat",
"cancel": "Zrušit",
"migrating": "Aktualizace štítku v existujících e-mailech…",
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech"
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech",
"nesting": {
"label": "Vnořené štítky",
"description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom."
},
"parent_field": "Nadřazený štítek",
"no_parent": "Bez nadřazeného štítku",
"too_long": "Tato cesta štítku je příliš dlouhá (nejvýše {max} znaků)",
"has_children_locked": "Pod tímto štítkem jsou vnořeny další štítky, proto jsou jeho název a nadřazený štítek uzamčeny. Nejprve je přesuňte nebo odeberte.",
"has_children_delete": "Nejprve odeberte štítky vnořené pod tímto",
"visibility_field": "Viditelnost v postranním panelu",
"visibility": {
"show": "Zobrazit",
"unread": "Zobrazit při nepřečtených",
"hide": "Skrýt"
}
},
"notifications": {
"test_sound": "Otestovat zvuk oznámení",
@@ -2001,8 +2041,7 @@
"delete": "Odstranit",
"mark_as_spam": "Nahlásit spam",
"not_spam": "Není spam",
"color_tag": "Štítek",
"remove_color": "Odebrat štítek",
"tag": "Štítek",
"items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání",
@@ -2336,6 +2375,9 @@
"section_address_book": "Adresář",
"select_address_book": "Vyberte adresář...",
"section_identity": "Jméno a identita",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
"type_organization": "Organizace",
"section_work": "Práce a organizace",
"prefix": "Titul",
"prefix_placeholder": "Dr., Pan, Paní",
@@ -2420,7 +2462,7 @@
"cancel": "Zrušit",
"creating": "Vytváření...",
"updating": "Aktualizování...",
"name_required": "Je vyžadováno alespoň jméno nebo příjmení",
"name_required": "Zadejte jméno, příjmení nebo organizaci",
"email_invalid": "Zadejte platnou e-mailovou adresu",
"email_error_inline": "Neplatný formát e-mailové adresy",
"save_failed": "Uložení kontaktu selhalo",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Nulstil",
"demo_tour": "Rundvisning",
"tags": "Tags",
"show_all_tags": "Vis alle ({count})",
"show_fewer_tags": "Vis færre",
"folders": "Mapper",
"shared": "Delt",
"mail": "Mail",
@@ -298,6 +300,7 @@
"print": "Udskriv",
"view_source": "Vis kilde",
"export_email": "Eksportér som .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importér .eml eller .zip",
"keyboard_shortcuts": "Tastaturgenveje (?)",
"email_source": "E-mail-kilde",
@@ -324,13 +327,15 @@
"view_contact": "Vis kontakt",
"message_details": "Beskeddetaljer",
"more_reply_options": "Flere svar-muligheder",
"set_color": "Sæt tag",
"set_tag": "Sæt tag",
"tag": "Tag",
"more_actions": "Flere handlinger",
"previous": "Forrige",
"next": "Næste",
"move_to": "Flyt til...",
"remove_color": "Fjern tag",
"remove_tag": "Fjern tag",
"tag_filter_placeholder": "Filtrer tags",
"tag_no_matches": "Ingen matchende tags",
"more_count": "+{count} mere",
"characters_count": "{count} tegn",
"quick_reply_placeholder": "Skriv et hurtigt svar...",
@@ -424,17 +429,6 @@
"message_id": "Besked-ID",
"list_info": "Listeinformation"
},
"color_tag": {
"title": "Farvetag",
"red": "Rød",
"orange": "Orange",
"yellow": "Gul",
"green": "Grøn",
"blue": "Blå",
"purple": "Lilla",
"pink": "Pink",
"none": "Ingen"
},
"tooltips": {
"reply": "Svar (r)",
"reply_all": "Svar alle (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Visningsnavn",
"autocomplete_search_server": "Søg på serveren",
"autocomplete_searching": "Søger...",
"toolbar": {
"bold": "Fed",
"italic": "Kursiv",
"underline": "Understreget",
"strikethrough": "Gennemstreget",
"text_color": "Tekstfarve",
"remove_color": "Fjern farve",
"heading_1": "Overskrift 1",
"heading_2": "Overskrift 2",
"bullet_list": "Punktopstilling",
"ordered_list": "Nummereret liste",
"quote": "Citat",
"code_block": "Kodeblok",
"align_left": "Venstrejusteret",
"align_center": "Centreret",
"align_right": "Højrejusteret",
"text_direction": "Tekstretning (RTL/LTR)",
"link": "Link",
"table": "Tabel",
"clear_formatting": "Ryd formatering",
"undo": "Fortryd",
"redo": "Gentag",
"add_row_above": "Tilføj række over",
"add_row_below": "Tilføj række under",
"add_column_before": "Tilføj kolonne før",
"add_column_after": "Tilføj kolonne efter",
"delete_row": "Slet række",
"delete_column": "Slet kolonne",
"toggle_header_row": "Slå overskriftsrække til/fra",
"delete_table": "Slet tabel",
"pick_size": "Vælg størrelse"
},
"send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "E-mail-tags",
"description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.",
"add_keyword": "Tilføj tag",
"reset_defaults": "Nulstil til standard",
"label_field": "Visningsnavn",
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
"id_field": "Tag-ID",
@@ -999,7 +1024,22 @@
"add": "Tilføj",
"cancel": "Annuller",
"migrating": "Opdaterer tag på eksisterende e-mails…",
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails"
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails",
"nesting": {
"label": "Indlejrede tags",
"description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet."
},
"parent_field": "Overordnet tag",
"no_parent": "Intet overordnet tag",
"too_long": "Denne tagsti er for lang (højst {max} tegn)",
"has_children_locked": "Andre tags er indlejret under dette, så dets navn og overordnede tag er låst. Flyt eller fjern dem først.",
"has_children_delete": "Fjern først de tags, der er indlejret under dette",
"visibility_field": "Synlighed i sidepanel",
"visibility": {
"show": "Vis",
"unread": "Vis ved ulæste",
"hide": "Skjul"
}
},
"notifications": {
"test_sound": "Test notifikationslyd",
@@ -2001,8 +2041,7 @@
"delete": "Slet",
"mark_as_spam": "Rapportér spam",
"not_spam": "Ikke spam",
"color_tag": "Tag",
"remove_color": "Fjern tag",
"tag": "Tag",
"items_selected": "{count} e-mails valgt",
"edit_draft": "Redigér kladde",
"cancel_scheduled_send": "Annuller afsendelse",
@@ -2336,6 +2375,9 @@
"section_address_book": "Adressebog",
"select_address_book": "Vælg en adressebog...",
"section_identity": "Navn & identitet",
"contact_type": "Kontakttype",
"type_person": "Person",
"type_organization": "Organisation",
"section_work": "Arbejde & organisation",
"prefix": "Præfiks",
"prefix_placeholder": "Dr., hr., fru",
@@ -2420,7 +2462,7 @@
"cancel": "Annuller",
"creating": "Opretter...",
"updating": "Opdaterer...",
"name_required": "Mindst et fornavn eller efternavn er påkrævet",
"name_required": "Angiv et fornavn, efternavn eller en organisation",
"email_invalid": "Indtast en gyldig e-mailadresse",
"email_error_inline": "Ugyldigt e-mailformat",
"save_failed": "Kunne ikke gemme kontakt",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
"tags": "Tags",
"show_all_tags": "Alle anzeigen ({count})",
"show_fewer_tags": "Weniger anzeigen",
"folders": "Ordner",
"mail": "E-Mail",
"nav_label": "Navigation",
@@ -298,6 +300,7 @@
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml oder .zip importieren",
"keyboard_shortcuts": "Tastaturkürzel (?)",
"email_source": "E-Mail-Quelltext",
@@ -324,11 +327,13 @@
"view_contact": "Kontakt anzeigen",
"message_details": "Nachrichtendetails",
"more_reply_options": "Weitere Antwortoptionen",
"set_color": "Label setzen",
"set_tag": "Label setzen",
"tag": "Label",
"more_actions": "Weitere Aktionen",
"move_to": "Verschieben nach...",
"remove_color": "Label entfernen",
"remove_tag": "Label entfernen",
"tag_filter_placeholder": "Labels filtern",
"tag_no_matches": "Keine passenden Labels",
"more_count": "+{count} weitere",
"characters_count": "{count} Zeichen",
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
@@ -397,17 +402,6 @@
"message_id": "Nachrichten-ID",
"list_info": "Listeninformationen"
},
"color_tag": {
"title": "Farb-Tag",
"red": "Rot",
"orange": "Orange",
"yellow": "Gelb",
"green": "Grün",
"blue": "Blau",
"purple": "Violett",
"pink": "Rosa",
"none": "Keine"
},
"tooltips": {
"reply": "Antworten",
"reply_all": "Allen antworten (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Anzeigename",
"autocomplete_search_server": "Auf dem Server suchen",
"autocomplete_searching": "Suche läuft...",
"toolbar": {
"bold": "Fett",
"italic": "Kursiv",
"underline": "Unterstrichen",
"strikethrough": "Durchgestrichen",
"text_color": "Textfarbe",
"remove_color": "Farbe entfernen",
"heading_1": "Überschrift 1",
"heading_2": "Überschrift 2",
"bullet_list": "Aufzählung",
"ordered_list": "Nummerierte Liste",
"quote": "Zitat",
"code_block": "Codeblock",
"align_left": "Linksbündig",
"align_center": "Zentriert",
"align_right": "Rechtsbündig",
"text_direction": "Schreibrichtung (RTL/LTR)",
"link": "Link",
"table": "Tabelle",
"clear_formatting": "Formatierung löschen",
"undo": "Rückgängig",
"redo": "Wiederholen",
"add_row_above": "Zeile oberhalb einfügen",
"add_row_below": "Zeile unterhalb einfügen",
"add_column_before": "Spalte davor einfügen",
"add_column_after": "Spalte danach einfügen",
"delete_row": "Zeile löschen",
"delete_column": "Spalte löschen",
"toggle_header_row": "Kopfzeile umschalten",
"delete_table": "Tabelle löschen",
"pick_size": "Größe wählen"
},
"send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "E-Mail-Labels",
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
"add_keyword": "Label hinzufügen",
"reset_defaults": "Auf Standard zurücksetzen",
"label_field": "Anzeigename",
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
"id_field": "Label-ID",
@@ -996,7 +1021,22 @@
"add": "Hinzufügen",
"cancel": "Abbrechen",
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden",
"nesting": {
"label": "Verschachtelte Labels",
"description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen."
},
"parent_field": "Übergeordnetes Label",
"no_parent": "Kein übergeordnetes Label",
"too_long": "Dieser Label-Pfad ist zu lang (höchstens {max} Zeichen)",
"has_children_locked": "Unter diesem Label sind andere Labels verschachtelt, daher sind Name und übergeordnetes Label gesperrt. Verschieben oder entfernen Sie diese zuerst.",
"has_children_delete": "Entfernen Sie zuerst die Labels, die unter diesem verschachtelt sind",
"visibility_field": "Sichtbarkeit in der Seitenleiste",
"visibility": {
"show": "Anzeigen",
"unread": "Bei Ungelesenen anzeigen",
"hide": "Ausblenden"
}
},
"notifications": {
"test_sound": "Benachrichtigungston testen",
@@ -2001,8 +2041,7 @@
"delete": "Löschen",
"mark_as_spam": "Spam melden",
"not_spam": "Kein Spam",
"color_tag": "Label",
"remove_color": "Label entfernen",
"tag": "Label",
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen",
@@ -2336,6 +2375,9 @@
"section_address_book": "Verzeichnis",
"select_address_book": "Verzeichnis auswählen...",
"section_identity": "Name & Identität",
"contact_type": "Kontakttyp",
"type_person": "Person",
"type_organization": "Organisation",
"section_work": "Beruf & Organisation",
"prefix": "Anrede",
"prefix_placeholder": "Dr., Herr, Frau",
@@ -2420,7 +2462,7 @@
"cancel": "Abbrechen",
"creating": "Wird erstellt...",
"updating": "Wird aktualisiert...",
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"name_required": "Bitte Vorname, Nachname oder Organisation angeben",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden",
+61 -19
View File
@@ -130,6 +130,8 @@
"demo_reset": "Reset",
"demo_tour": "Tour",
"tags": "Tags",
"show_all_tags": "Show all ({count})",
"show_fewer_tags": "Show less",
"folders": "Folders",
"shared": "Shared",
"mail": "Mail",
@@ -298,6 +300,7 @@
"print": "Print",
"view_source": "View source",
"export_email": "Export as .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Import .eml or .zip",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source",
@@ -324,13 +327,15 @@
"view_contact": "View contact",
"message_details": "Message Details",
"more_reply_options": "More reply options",
"set_color": "Set tag",
"set_tag": "Set tag",
"tag": "Tag",
"more_actions": "More actions",
"previous": "Prev",
"next": "Next",
"move_to": "Move to...",
"remove_color": "Remove tag",
"remove_tag": "Remove tag",
"tag_filter_placeholder": "Filter tags",
"tag_no_matches": "No matching tags",
"more_count": "+{count} more",
"characters_count": "{count} characters",
"quick_reply_placeholder": "Write a quick reply...",
@@ -424,17 +429,6 @@
"message_id": "Message ID",
"list_info": "List Information"
},
"color_tag": {
"title": "Color Tag",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"purple": "Purple",
"pink": "Pink",
"none": "None"
},
"tooltips": {
"reply": "Reply (r)",
"reply_all": "Reply All (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Display name",
"autocomplete_search_server": "Search the server",
"autocomplete_searching": "Searching...",
"toolbar": {
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"strikethrough": "Strikethrough",
"text_color": "Text color",
"remove_color": "Remove color",
"heading_1": "Heading 1",
"heading_2": "Heading 2",
"bullet_list": "Bullet list",
"ordered_list": "Ordered list",
"quote": "Quote",
"code_block": "Code block",
"align_left": "Align left",
"align_center": "Align center",
"align_right": "Align right",
"text_direction": "Text direction (RTL/LTR)",
"link": "Link",
"table": "Table",
"clear_formatting": "Clear formatting",
"undo": "Undo",
"redo": "Redo",
"add_row_above": "Add row above",
"add_row_below": "Add row below",
"add_column_before": "Add column before",
"add_column_after": "Add column after",
"delete_row": "Delete row",
"delete_column": "Delete column",
"toggle_header_row": "Toggle header row",
"delete_table": "Delete table",
"pick_size": "Pick size"
},
"send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain."
},
"confirm_dialog": {
@@ -984,9 +1010,8 @@
},
"keywords": {
"title": "Email Tags",
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
"description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Tag",
"reset_defaults": "Reset to Defaults",
"label_field": "Display Name",
"label_placeholder": "e.g. Work, Personal, Urgent",
"id_field": "Tag ID",
@@ -999,7 +1024,22 @@
"add": "Add",
"cancel": "Cancel",
"migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update tag on existing emails"
"migration_error": "Failed to update tag on existing emails",
"nesting": {
"label": "Nested Tags",
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
},
"parent_field": "Parent Tag",
"no_parent": "No parent",
"too_long": "This tag path is too long (at most {max} characters)",
"has_children_locked": "Other tags are nested under this one, so its name and parent are locked. Move or remove them first.",
"has_children_delete": "Remove the tags nested under this one first",
"visibility_field": "Sidebar visibility",
"visibility": {
"show": "Show",
"unread": "Show if unread",
"hide": "Hide"
}
},
"notifications": {
"test_sound": "Test notification sound",
@@ -2001,8 +2041,7 @@
"delete": "Delete",
"mark_as_spam": "Report spam",
"not_spam": "Not spam",
"color_tag": "Tag",
"remove_color": "Remove tag",
"tag": "Tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send",
@@ -2337,6 +2376,9 @@
"section_address_book": "Directory",
"select_address_book": "Select a directory...",
"section_identity": "Name & Identity",
"contact_type": "Contact type",
"type_person": "Person",
"type_organization": "Organization",
"section_work": "Work & Organization",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Mr., Mrs.",
@@ -2421,7 +2463,7 @@
"cancel": "Cancel",
"creating": "Creating...",
"updating": "Updating...",
"name_required": "At least a first name or last name is required",
"name_required": "Enter a first name, last name, or organization",
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Restablecer",
"demo_tour": "Tour",
"tags": "Etiquetas",
"show_all_tags": "Mostrar todo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Carpetas",
"mail": "Correo",
"nav_label": "Navegación",
@@ -298,6 +300,7 @@
"print": "Imprimir",
"view_source": "Ver código fuente",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importar .eml o .zip",
"keyboard_shortcuts": "Atajos de teclado (?)",
"email_source": "Código Fuente del Correo",
@@ -324,11 +327,13 @@
"view_contact": "Ver contacto",
"message_details": "Detalles del Mensaje",
"more_reply_options": "Más opciones de respuesta",
"set_color": "Establecer etiqueta",
"set_tag": "Establecer etiqueta",
"tag": "Etiqueta",
"more_actions": "Más acciones",
"move_to": "Mover a...",
"remove_color": "Eliminar etiqueta",
"remove_tag": "Eliminar etiqueta",
"tag_filter_placeholder": "Filtrar etiquetas",
"tag_no_matches": "No hay etiquetas coincidentes",
"more_count": "+{count} más",
"characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escriba una respuesta rápida...",
@@ -397,17 +402,6 @@
"message_id": "ID del Mensaje",
"list_info": "Información de Lista"
},
"color_tag": {
"title": "Etiqueta de Color",
"red": "Rojo",
"orange": "Naranja",
"yellow": "Amarillo",
"green": "Verde",
"blue": "Azul",
"purple": "Morado",
"pink": "Rosa",
"none": "Ninguno"
},
"tooltips": {
"reply": "Responder",
"reply_all": "Responder a todos (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Nombre para mostrar",
"autocomplete_search_server": "Buscar en el servidor",
"autocomplete_searching": "Buscando...",
"toolbar": {
"bold": "Negrita",
"italic": "Cursiva",
"underline": "Subrayado",
"strikethrough": "Tachado",
"text_color": "Color del texto",
"remove_color": "Quitar color",
"heading_1": "Encabezado 1",
"heading_2": "Encabezado 2",
"bullet_list": "Lista con viñetas",
"ordered_list": "Lista numerada",
"quote": "Cita",
"code_block": "Bloque de código",
"align_left": "Alinear a la izquierda",
"align_center": "Centrar",
"align_right": "Alinear a la derecha",
"text_direction": "Dirección del texto (RTL/LTR)",
"link": "Enlace",
"table": "Tabla",
"clear_formatting": "Borrar formato",
"undo": "Deshacer",
"redo": "Rehacer",
"add_row_above": "Añadir fila encima",
"add_row_below": "Añadir fila debajo",
"add_column_before": "Añadir columna antes",
"add_column_after": "Añadir columna después",
"delete_row": "Eliminar fila",
"delete_column": "Eliminar columna",
"toggle_header_row": "Alternar fila de encabezado",
"delete_table": "Eliminar tabla",
"pick_size": "Elegir tamaño"
},
"send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Etiquetas de correo",
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
"add_keyword": "Añadir etiqueta",
"reset_defaults": "Restablecer valores predeterminados",
"label_field": "Nombre para mostrar",
"label_placeholder": "ej. Trabajo, Personal, Urgente",
"id_field": "ID de etiqueta",
@@ -996,7 +1021,22 @@
"add": "Añadir",
"cancel": "Cancelar",
"migrating": "Actualizando etiqueta en correos existentes…",
"migration_error": "Error al actualizar la etiqueta en correos existentes"
"migration_error": "Error al actualizar la etiqueta en correos existentes",
"nesting": {
"label": "Etiquetas anidadas",
"description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral."
},
"parent_field": "Etiqueta principal",
"no_parent": "Sin etiqueta principal",
"too_long": "Esta ruta de etiqueta es demasiado larga (máximo {max} caracteres)",
"has_children_locked": "Hay otras etiquetas anidadas bajo esta, por lo que su nombre y su etiqueta principal están bloqueados. Muévelas o elimínalas primero.",
"has_children_delete": "Elimina primero las etiquetas anidadas bajo esta",
"visibility_field": "Visibilidad en la barra lateral",
"visibility": {
"show": "Mostrar",
"unread": "Mostrar si hay no leídos",
"hide": "Ocultar"
}
},
"notifications": {
"test_sound": "Probar sonido de notificación",
@@ -2001,8 +2041,7 @@
"delete": "Eliminar",
"mark_as_spam": "Reportar spam",
"not_spam": "No es spam",
"color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"tag": "Etiqueta",
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío",
@@ -2336,6 +2375,9 @@
"section_address_book": "Directorio",
"select_address_book": "Seleccionar un directorio...",
"section_identity": "Nombre e identidad",
"contact_type": "Tipo de contacto",
"type_person": "Persona",
"type_organization": "Organización",
"section_work": "Trabajo y organización",
"prefix": "Prefijo",
"prefix_placeholder": "Dr., Sr., Sra.",
@@ -2420,7 +2462,7 @@
"cancel": "Cancelar",
"creating": "Creando...",
"updating": "Actualizando...",
"name_required": "Se requiere al menos un nombre o apellido",
"name_required": "Introduce un nombre, un apellido o una organización",
"email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "بازنشانی",
"demo_tour": "تور",
"tags": "برچسب‌ها",
"show_all_tags": "نمایش همه ({count})",
"show_fewer_tags": "نمایش کمتر",
"folders": "پوشه‌ها",
"shared": "اشتراکی",
"mail": "ایمیل",
@@ -298,6 +300,7 @@
"print": "چاپ",
"view_source": "مشاهده منبع",
"export_email": "خروجی .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "وارد کردن .eml یا .zip",
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
"email_source": "منبع ایمیل",
@@ -324,13 +327,15 @@
"view_contact": "مشاهده مخاطب",
"message_details": "جزئیات پیام",
"more_reply_options": "گزینه‌های بیشتر پاسخ",
"set_color": "تنظیم برچسب",
"set_tag": "تنظیم برچسب",
"tag": "برچسب",
"more_actions": "عملیات بیشتر",
"previous": "قبلی",
"next": "بعدی",
"move_to": "انتقال به...",
"remove_color": "حذف برچسب",
"remove_tag": "حذف برچسب",
"tag_filter_placeholder": "فیلتر برچسب‌ها",
"tag_no_matches": "برچسب مطابقی یافت نشد",
"more_count": "+{count} بیشتر",
"characters_count": "{count} کاراکتر",
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
@@ -424,17 +429,6 @@
"message_id": "شناسه پیام",
"list_info": "اطلاعات لیست"
},
"color_tag": {
"title": "برچسب رنگی",
"red": "قرمز",
"orange": "نارنجی",
"yellow": "زرد",
"green": "سبز",
"blue": "آبی",
"purple": "بنفش",
"pink": "صورتی",
"none": "هیچکدام"
},
"tooltips": {
"reply": "پاسخ (r)",
"reply_all": "پاسخ به همه (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "نام نمایشی",
"autocomplete_search_server": "جستجو در سرور",
"autocomplete_searching": "در حال جستجو...",
"toolbar": {
"bold": "پررنگ",
"italic": "کج",
"underline": "زیرخط‌دار",
"strikethrough": "خط‌خورده",
"text_color": "رنگ متن",
"remove_color": "حذف رنگ",
"heading_1": "سرفصل ۱",
"heading_2": "سرفصل ۲",
"bullet_list": "فهرست نشانه‌دار",
"ordered_list": "فهرست شماره‌دار",
"quote": "نقل‌قول",
"code_block": "بلوک کد",
"align_left": "تراز چپ",
"align_center": "وسط‌چین",
"align_right": "تراز راست",
"text_direction": "جهت متن (RTL/LTR)",
"link": "پیوند",
"table": "جدول",
"clear_formatting": "پاک کردن قالب‌بندی",
"undo": "واگرد",
"redo": "انجام دوباره",
"add_row_above": "افزودن ردیف در بالا",
"add_row_below": "افزودن ردیف در پایین",
"add_column_before": "افزودن ستون قبل",
"add_column_after": "افزودن ستون بعد",
"delete_row": "حذف ردیف",
"delete_column": "حذف ستون",
"toggle_header_row": "تغییر وضعیت ردیف سرصفحه",
"delete_table": "حذف جدول",
"pick_size": "انتخاب اندازه"
},
"send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "برچسب‌های ایمیل",
"description": "برچسب‌ها را برای سازمان‌دهی ایمیل‌ها تعریف کنید",
"add_keyword": "افزودن برچسب",
"reset_defaults": "بازنشانی به پیش‌فرض",
"label_field": "نام نمایشی",
"label_placeholder": "مثال: کاری، شخصی، فوری",
"id_field": "شناسه برچسب",
@@ -999,7 +1024,22 @@
"add": "افزودن",
"cancel": "انصراف",
"migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…",
"migration_error": "به‌روزرسانی برچسب ناموفق بود"
"migration_error": "به‌روزرسانی برچسب ناموفق بود",
"nesting": {
"label": "برچسب‌های تودرتو",
"description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید."
},
"parent_field": "برچسب والد",
"no_parent": "بدون برچسب والد",
"too_long": "این مسیر برچسب خیلی طولانی است (حداکثر {max} نویسه)",
"has_children_locked": "برچسب‌های دیگری زیر این برچسب قرار دارند، بنابراین نام و برچسب والد آن قفل است. ابتدا آن‌ها را جابه‌جا یا حذف کنید.",
"has_children_delete": "ابتدا برچسب‌های زیرمجموعهٔ این برچسب را حذف کنید",
"visibility_field": "نمایش در نوار کناری",
"visibility": {
"show": "نمایش",
"unread": "نمایش در صورت وجود خوانده‌نشده",
"hide": "پنهان کردن"
}
},
"notifications": {
"test_sound": "تست صدای اعلان",
@@ -2001,8 +2041,7 @@
"delete": "حذف",
"mark_as_spam": "گزارش هرزنامه",
"not_spam": "هرزنامه نیست",
"color_tag": "برچسب",
"remove_color": "حذف برچسب",
"tag": "برچسب",
"items_selected": "{count} ایمیل انتخاب شده",
"edit_draft": "ویرایش پیش‌نویس",
"cancel_scheduled_send": "لغو ارسال",
@@ -2337,6 +2376,9 @@
"section_address_book": "دفترچه",
"select_address_book": "انتخاب دفترچه...",
"section_identity": "نام و هویت",
"contact_type": "نوع مخاطب",
"type_person": "شخص",
"type_organization": "سازمان",
"section_work": "کار و سازمان",
"prefix": "پیشوند",
"prefix_placeholder": "دکتر، مهندس",
@@ -2421,7 +2463,7 @@
"cancel": "انصراف",
"creating": "در حال ایجاد...",
"updating": "در حال به‌روزرسانی...",
"name_required": "حداقل نام یا نام خانوادگی الزامی است",
"name_required": "نام، نام خانوادگی یا سازمان را وارد کنید",
"email_invalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید",
"email_error_inline": "فرمت ایمیل نامعتبر است",
"save_failed": "ذخیره مخاطب ناموفق بود",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
"tags": "Étiquettes",
"show_all_tags": "Tout afficher ({count})",
"show_fewer_tags": "Afficher moins",
"folders": "Dossiers",
"mail": "Messagerie",
"nav_label": "Navigation",
@@ -298,6 +300,7 @@
"print": "Imprimer",
"view_source": "Voir la source",
"export_email": "Exporter en .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importer .eml ou .zip",
"keyboard_shortcuts": "Raccourcis clavier (?)",
"email_source": "Source de l'email",
@@ -324,11 +327,13 @@
"view_contact": "Voir le contact",
"message_details": "Détails du message",
"more_reply_options": "Plus d'options de réponse",
"set_color": "Définir l'étiquette",
"set_tag": "Définir l'étiquette",
"tag": "Étiquette",
"more_actions": "Plus d'actions",
"move_to": "Déplacer vers...",
"remove_color": "Retirer l'étiquette",
"remove_tag": "Retirer l'étiquette",
"tag_filter_placeholder": "Filtrer les étiquettes",
"tag_no_matches": "Aucune étiquette correspondante",
"more_count": "+{count} de plus",
"characters_count": "{count} caractères",
"quick_reply_placeholder": "Écrivez une réponse rapide...",
@@ -397,17 +402,6 @@
"message_id": "ID du message",
"list_info": "Information de liste"
},
"color_tag": {
"title": "Étiquette de couleur",
"red": "Rouge",
"orange": "Orange",
"yellow": "Jaune",
"green": "Vert",
"blue": "Bleu",
"purple": "Violet",
"pink": "Rose",
"none": "Aucune"
},
"tooltips": {
"reply": "Répondre",
"reply_all": "Répondre à tous (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Nom d'affichage",
"autocomplete_search_server": "Rechercher sur le serveur",
"autocomplete_searching": "Recherche en cours...",
"toolbar": {
"bold": "Gras",
"italic": "Italique",
"underline": "Souligné",
"strikethrough": "Barré",
"text_color": "Couleur du texte",
"remove_color": "Supprimer la couleur",
"heading_1": "Titre 1",
"heading_2": "Titre 2",
"bullet_list": "Liste à puces",
"ordered_list": "Liste numérotée",
"quote": "Citation",
"code_block": "Bloc de code",
"align_left": "Aligner à gauche",
"align_center": "Centrer",
"align_right": "Aligner à droite",
"text_direction": "Sens du texte (RTL/LTR)",
"link": "Lien",
"table": "Tableau",
"clear_formatting": "Effacer la mise en forme",
"undo": "Annuler",
"redo": "Rétablir",
"add_row_above": "Insérer une ligne au-dessus",
"add_row_below": "Insérer une ligne en dessous",
"add_column_before": "Insérer une colonne avant",
"add_column_after": "Insérer une colonne après",
"delete_row": "Supprimer la ligne",
"delete_column": "Supprimer la colonne",
"toggle_header_row": "Basculer la ligne d'en-tête",
"delete_table": "Supprimer le tableau",
"pick_size": "Choisir la taille"
},
"send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Étiquettes de messagerie",
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
"add_keyword": "Ajouter une étiquette",
"reset_defaults": "Réinitialiser par défaut",
"label_field": "Nom d'affichage",
"label_placeholder": "ex. Travail, Personnel, Urgent",
"id_field": "ID d'étiquette",
@@ -996,7 +1021,22 @@
"add": "Ajouter",
"cancel": "Annuler",
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants",
"nesting": {
"label": "Étiquettes imbriquées",
"description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale."
},
"parent_field": "Étiquette parente",
"no_parent": "Aucune étiquette parente",
"too_long": "Ce chemin d'étiquette est trop long ({max} caractères au maximum)",
"has_children_locked": "D'autres étiquettes sont imbriquées sous celle-ci, son nom et son étiquette parente sont donc verrouillés. Déplacez-les ou supprimez-les d'abord.",
"has_children_delete": "Retirez d'abord les étiquettes imbriquées sous celle-ci",
"visibility_field": "Visibilité dans la barre latérale",
"visibility": {
"show": "Afficher",
"unread": "Afficher si non lus",
"hide": "Masquer"
}
},
"notifications": {
"test_sound": "Tester le son de notification",
@@ -2001,8 +2041,7 @@
"delete": "Supprimer",
"mark_as_spam": "Signaler comme spam",
"not_spam": "Pas un spam",
"color_tag": "Étiquette",
"remove_color": "Supprimer l'étiquette",
"tag": "Étiquette",
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi",
@@ -2336,6 +2375,9 @@
"section_address_book": "Répertoire",
"select_address_book": "Sélectionner un répertoire...",
"section_identity": "Nom et identité",
"contact_type": "Type de contact",
"type_person": "Personne",
"type_organization": "Organisation",
"section_work": "Travail et organisation",
"prefix": "Préfixe",
"prefix_placeholder": "Dr., M., Mme",
@@ -2420,7 +2462,7 @@
"cancel": "Annuler",
"creating": "Création...",
"updating": "Mise à jour...",
"name_required": "Un prénom ou un nom est requis",
"name_required": "Saisissez un prénom, un nom ou une organisation",
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact",
+60 -18
View File
@@ -122,6 +122,8 @@
"demo_reset": "אִתחוּל",
"demo_tour": "סִיוּר",
"tags": "תגים",
"show_all_tags": "הצג הכל ({count})",
"show_fewer_tags": "הצג פחות",
"folders": "תיקיות",
"mail": "דוֹאַר",
"nav_label": "ניווט",
@@ -245,6 +247,7 @@
"print": "הדפס",
"view_source": "צפה במקור",
"export_email": "ייצא כ-.eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "ייבוא .eml",
"keyboard_shortcuts": "קיצורי מקשים (?)",
"email_source": "מקור דוא\"ל",
@@ -271,13 +274,15 @@
"view_contact": "הצג איש קשר",
"message_details": "פרטי הודעה",
"more_reply_options": "אפשרויות תשובה נוספות",
"set_color": "הגדר תג",
"set_tag": "הגדר תג",
"tag": "תג",
"more_actions": "עוד פעולות",
"previous": "הקודם",
"next": "הבא",
"move_to": "העבר ל...",
"remove_color": "הסר תג",
"remove_tag": "הסר תג",
"tag_filter_placeholder": "סינון תגים",
"tag_no_matches": "אין תגים תואמים",
"more_count": "+{count}נוספים",
"characters_count": "{count} תווים",
"quick_reply_placeholder": "תשובה מהירה",
@@ -346,17 +351,6 @@
"message_id": "מזהה הודעה",
"list_info": "רשימת מידע"
},
"color_tag": {
"title": "תג צבע",
"red": "אדום",
"orange": "כתום",
"yellow": "צהוב",
"green": "ירוק",
"blue": "כחול",
"purple": "סגול",
"pink": "ורוד",
"none": "אין"
},
"tooltips": {
"reply": "תשובה (ר)",
"reply_all": "השב לכולם (א)",
@@ -651,6 +645,38 @@
"recipient_name_placeholder": "שם תצוגה",
"autocomplete_search_server": "חיפוש בשרת",
"autocomplete_searching": "מחפש...",
"toolbar": {
"bold": "מודגש",
"italic": "נטוי",
"underline": "קו תחתון",
"strikethrough": "קו חוצה",
"text_color": "צבע טקסט",
"remove_color": "הסרת צבע",
"heading_1": "כותרת 1",
"heading_2": "כותרת 2",
"bullet_list": "רשימת תבליטים",
"ordered_list": "רשימה ממוספרת",
"quote": "ציטוט",
"code_block": "בלוק קוד",
"align_left": "יישור לשמאל",
"align_center": "מרכוז",
"align_right": "יישור לימין",
"text_direction": "כיוון טקסט (RTL/LTR)",
"link": "קישור",
"table": "טבלה",
"clear_formatting": "ניקוי עיצוב",
"undo": "ביטול",
"redo": "ביצוע מחדש",
"add_row_above": "הוספת שורה מעל",
"add_row_below": "הוספת שורה מתחת",
"add_column_before": "הוספת עמודה לפני",
"add_column_after": "הוספת עמודה אחרי",
"delete_row": "מחיקת שורה",
"delete_column": "מחיקת עמודה",
"toggle_header_row": "החלפת שורת כותרת",
"delete_table": "מחיקת טבלה",
"pick_size": "בחירת גודל"
},
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
},
"confirm_dialog": {
@@ -948,7 +974,6 @@
"title": "מילות מפתח בדוא\"ל",
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
"add_keyword": "הוסף מילת מפתח",
"reset_defaults": "אפס לברירות מחדל",
"label_field": "שם תצוגה",
"label_placeholder": "למשל עבודה, אישי, דחוף",
"id_field": "מזהה מילת מפתח",
@@ -961,7 +986,22 @@
"add": "לְהוֹסִיף",
"cancel": "לְבַטֵל",
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות"
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
"nesting": {
"label": "תגים מקוננים",
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
},
"parent_field": "תג אב",
"no_parent": "ללא תג אב",
"too_long": "נתיב התג ארוך מדי (עד {max} תווים)",
"has_children_locked": "תגים אחרים מקוננים תחת תג זה, ולכן שמו ותג האב שלו נעולים. העבר או הסר אותם תחילה.",
"has_children_delete": "הסר תחילה את התגים המקוננים תחת תג זה",
"visibility_field": "הצגה בסרגל הצד",
"visibility": {
"show": "הצג",
"unread": "הצג כשיש שלא נקראו",
"hide": "הסתר"
}
},
"notifications": {
"test_sound": "צליל הודעת בדיקה",
@@ -1967,8 +2007,7 @@
"delete": "לִמְחוֹק",
"mark_as_spam": "דווח על ספאם",
"not_spam": "לא ספאם",
"color_tag": "תווית",
"remove_color": "הסר תווית",
"tag": "תווית",
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
"edit_draft": "ערוך טיוטה",
"cancel_scheduled_send": "ביטול שליחה",
@@ -2250,6 +2289,9 @@
"section_address_book": "ספרייה",
"select_address_book": "בחר ספרייה...",
"section_identity": "שם וזהות",
"contact_type": "סוג איש קשר",
"type_person": "אדם",
"type_organization": "ארגון",
"section_work": "עבודה וארגון",
"prefix": "קידומת",
"prefix_placeholder": "ד\"ר, מר, גברת.",
@@ -2334,7 +2376,7 @@
"cancel": "לְבַטֵל",
"creating": "יוצר...",
"updating": "מעדכן...",
"name_required": "נדרש לפחות שם פרטי או שם משפחה",
"name_required": "יש להזין שם פרטי, שם משפחה או ארגון",
"email_invalid": "נא להזין כתובת אימייל חוקית",
"email_error_inline": "פורמט אימייל לא חוקי",
"save_failed": "שמירת איש הקשר נכשלה",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Visszaállítás",
"demo_tour": "Bemutató",
"tags": "Címkék",
"show_all_tags": "Összes megjelenítése ({count})",
"show_fewer_tags": "Kevesebb megjelenítése",
"folders": "Mappák",
"shared": "Megosztott",
"mail": "Levelek",
@@ -298,6 +300,7 @@
"print": "Nyomtatás",
"view_source": "Forrás megtekintése",
"export_email": "Exportálás .eml-ként",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importálás .eml vagy .zip fájlból",
"keyboard_shortcuts": "Billentyűparancsok (?)",
"email_source": "E-mail forrás",
@@ -324,13 +327,15 @@
"view_contact": "Névjegy megtekintése",
"message_details": "Üzenet részletei",
"more_reply_options": "További válasz opciók",
"set_color": "Címke beállítása",
"set_tag": "Címke beállítása",
"tag": "Címke",
"more_actions": "További műveletek",
"previous": "Előző",
"next": "Következő",
"move_to": "Áthelyezés ide...",
"remove_color": "Címke eltávolítása",
"remove_tag": "Címke eltávolítása",
"tag_filter_placeholder": "Címkék szűrése",
"tag_no_matches": "Nincs találat a címkék közt",
"more_count": "+{count} további",
"characters_count": "{count} karakter",
"quick_reply_placeholder": "Gyors válasz írása...",
@@ -424,17 +429,6 @@
"message_id": "Üzenet azonosító",
"list_info": "Lista információk"
},
"color_tag": {
"title": "Színes címke",
"red": "Piros",
"orange": "Narancs",
"yellow": "Sárga",
"green": "Zöld",
"blue": "Kék",
"purple": "Lila",
"pink": "Rózsaszín",
"none": "Nincs"
},
"tooltips": {
"reply": "Válasz (r)",
"reply_all": "Válasz mindenkinek (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Megjelenített név",
"autocomplete_search_server": "Keresés a kiszolgálón",
"autocomplete_searching": "Keresés...",
"toolbar": {
"bold": "Félkövér",
"italic": "Dőlt",
"underline": "Aláhúzott",
"strikethrough": "Áthúzott",
"text_color": "Betűszín",
"remove_color": "Szín eltávolítása",
"heading_1": "Címsor 1",
"heading_2": "Címsor 2",
"bullet_list": "Felsorolás",
"ordered_list": "Számozott lista",
"quote": "Idézet",
"code_block": "Kódblokk",
"align_left": "Balra igazítás",
"align_center": "Középre igazítás",
"align_right": "Jobbra igazítás",
"text_direction": "Szövegirány (RTL/LTR)",
"link": "Hivatkozás",
"table": "Táblázat",
"clear_formatting": "Formázás törlése",
"undo": "Visszavonás",
"redo": "Újra",
"add_row_above": "Sor beszúrása fölé",
"add_row_below": "Sor beszúrása alá",
"add_column_before": "Oszlop beszúrása elé",
"add_column_after": "Oszlop beszúrása mögé",
"delete_row": "Sor törlése",
"delete_column": "Oszlop törlése",
"toggle_header_row": "Fejlécsor váltása",
"delete_table": "Táblázat törlése",
"pick_size": "Méret kiválasztása"
},
"send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "E-mail címkék",
"description": "Címkék definiálása az e-mailek színekkel történő rendezéséhez. Ezek JMAP kulcsszavakként tárolódnak a szerveren.",
"add_keyword": "Címke hozzáadása",
"reset_defaults": "Alapértelmezettek visszaállítása",
"label_field": "Megjelenített név",
"label_placeholder": "pl. Munka, Személyes, Sürgős",
"id_field": "Címke azonosító",
@@ -999,7 +1024,22 @@
"add": "Hozzáadás",
"cancel": "Mégse",
"migrating": "Címke frissítése a meglévő e-maileken...",
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken"
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken",
"nesting": {
"label": "Beágyazott címkék",
"description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon."
},
"parent_field": "Szülőcímke",
"no_parent": "Nincs szülőcímke",
"too_long": "Ez a címkeútvonal túl hosszú (legfeljebb {max} karakter)",
"has_children_locked": "Más címkék vannak beágyazva ez alá, ezért a neve és a szülőcímkéje zárolva van. Előbb helyezze át vagy távolítsa el őket.",
"has_children_delete": "Előbb távolítsa el az ez alá beágyazott címkéket",
"visibility_field": "Láthatóság az oldalsávon",
"visibility": {
"show": "Megjelenítés",
"unread": "Megjelenítés olvasatlanoknál",
"hide": "Elrejtés"
}
},
"notifications": {
"test_sound": "Értesítési hang tesztelése",
@@ -2001,8 +2041,7 @@
"delete": "Törlés",
"mark_as_spam": "Spam jelentése",
"not_spam": "Nem spam",
"color_tag": "Címke",
"remove_color": "Címke eltávolítása",
"tag": "Címke",
"items_selected": "{count} e-mail kijelölve",
"edit_draft": "Piszkozat szerkesztése",
"cancel_scheduled_send": "Küldés megszakítása",
@@ -2337,6 +2376,9 @@
"section_address_book": "Címtár",
"select_address_book": "Címtár kiválasztása...",
"section_identity": "Név és azonosság",
"contact_type": "Névjegy típusa",
"type_person": "Személy",
"type_organization": "Szervezet",
"section_work": "Munka és szervezet",
"prefix": "Előtag",
"prefix_placeholder": "Dr., Úr., Mrs.",
@@ -2421,7 +2463,7 @@
"cancel": "Mégse",
"creating": "Létrehozás...",
"updating": "Frissítés...",
"name_required": "Legalább a keresztnév vagy vezetéknév megadása kötelező",
"name_required": "Adjon meg egy keresztnevet, vezetéknevet vagy szervezetet",
"email_invalid": "Kérjük, adj meg egy érvényes e-mail címet",
"email_error_inline": "Érvénytelen e-mail formátum",
"save_failed": "Nem sikerült menteni a névjegyet",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Reimposta",
"demo_tour": "Tour",
"tags": "Etichette",
"show_all_tags": "Mostra tutto ({count})",
"show_fewer_tags": "Mostra meno",
"folders": "Cartelle",
"mail": "Posta",
"nav_label": "Navigazione",
@@ -298,6 +300,7 @@
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"email_source": "Sorgente del messaggio",
@@ -324,11 +327,13 @@
"view_contact": "Visualizza contatto",
"message_details": "Dettagli del messaggio",
"more_reply_options": "Più opzioni di risposta",
"set_color": "Imposta etichetta",
"set_tag": "Imposta etichetta",
"tag": "Etichetta",
"more_actions": "Altre azioni",
"move_to": "Sposta in...",
"remove_color": "Rimuovi etichetta",
"remove_tag": "Rimuovi etichetta",
"tag_filter_placeholder": "Filtra etichette",
"tag_no_matches": "Nessuna etichetta corrispondente",
"more_count": "+{count} altri",
"characters_count": "{count} caratteri",
"quick_reply_placeholder": "Scrivi una risposta veloce...",
@@ -397,17 +402,6 @@
"message_id": "ID messaggio",
"list_info": "Informazioni lista"
},
"color_tag": {
"title": "Etichetta colore",
"red": "Rosso",
"orange": "Arancione",
"yellow": "Giallo",
"green": "Verde",
"blue": "Blu",
"purple": "Viola",
"pink": "Rosa",
"none": "Nessuno"
},
"tooltips": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Nome visualizzato",
"autocomplete_search_server": "Cerca nel server",
"autocomplete_searching": "Ricerca in corso...",
"toolbar": {
"bold": "Grassetto",
"italic": "Corsivo",
"underline": "Sottolineato",
"strikethrough": "Barrato",
"text_color": "Colore del testo",
"remove_color": "Rimuovi colore",
"heading_1": "Titolo 1",
"heading_2": "Titolo 2",
"bullet_list": "Elenco puntato",
"ordered_list": "Elenco numerato",
"quote": "Citazione",
"code_block": "Blocco di codice",
"align_left": "Allinea a sinistra",
"align_center": "Centra",
"align_right": "Allinea a destra",
"text_direction": "Direzione del testo (RTL/LTR)",
"link": "Link",
"table": "Tabella",
"clear_formatting": "Cancella formattazione",
"undo": "Annulla",
"redo": "Ripeti",
"add_row_above": "Aggiungi riga sopra",
"add_row_below": "Aggiungi riga sotto",
"add_column_before": "Aggiungi colonna prima",
"add_column_after": "Aggiungi colonna dopo",
"delete_row": "Elimina riga",
"delete_column": "Elimina colonna",
"toggle_header_row": "Attiva/disattiva riga di intestazione",
"delete_table": "Elimina tabella",
"pick_size": "Scegli dimensione"
},
"send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Etichette e-mail",
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
"add_keyword": "Aggiungi etichetta",
"reset_defaults": "Ripristina predefiniti",
"label_field": "Nome visualizzato",
"label_placeholder": "es. Lavoro, Personale, Urgente",
"id_field": "ID etichetta",
@@ -996,7 +1021,22 @@
"add": "Aggiungi",
"cancel": "Annulla",
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti",
"nesting": {
"label": "Etichette nidificate",
"description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale."
},
"parent_field": "Etichetta principale",
"no_parent": "Nessuna etichetta principale",
"too_long": "Questo percorso di etichetta è troppo lungo (al massimo {max} caratteri)",
"has_children_locked": "Altre etichette sono nidificate sotto questa, quindi il suo nome e la sua etichetta principale sono bloccati. Spostale o rimuovile prima.",
"has_children_delete": "Rimuovi prima le etichette nidificate sotto questa",
"visibility_field": "Visibilità nella barra laterale",
"visibility": {
"show": "Mostra",
"unread": "Mostra se non letti",
"hide": "Nascondi"
}
},
"notifications": {
"test_sound": "Testa il suono di notifica",
@@ -2001,8 +2041,7 @@
"delete": "Elimina",
"mark_as_spam": "Segnala come spam",
"not_spam": "Non spam",
"color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"tag": "Etichetta",
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio",
@@ -2336,6 +2375,9 @@
"section_address_book": "Rubrica",
"select_address_book": "Seleziona una rubrica...",
"section_identity": "Nome e identità",
"contact_type": "Tipo di contatto",
"type_person": "Persona",
"type_organization": "Organizzazione",
"section_work": "Lavoro e organizzazione",
"prefix": "Prefisso",
"prefix_placeholder": "Dott., Sig., Sig.ra",
@@ -2420,7 +2462,7 @@
"cancel": "Annulla",
"creating": "Creazione...",
"updating": "Aggiornamento...",
"name_required": "È richiesto almeno un nome o cognome",
"name_required": "Inserisci un nome, un cognome o un'organizzazione",
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "リセット",
"demo_tour": "ツアー",
"tags": "タグ",
"show_all_tags": "すべて表示({count}",
"show_fewer_tags": "表示を減らす",
"folders": "フォルダ",
"mail": "メール",
"nav_label": "ナビゲーション",
@@ -298,6 +300,7 @@
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml または .zip をインポート",
"keyboard_shortcuts": "キーボードショートカット (?)",
"email_source": "メールソース",
@@ -324,11 +327,13 @@
"view_contact": "連絡先を表示",
"message_details": "メッセージの詳細",
"more_reply_options": "その他の返信オプション",
"set_color": "ラベルを設定",
"set_tag": "ラベルを設定",
"tag": "ラベル",
"more_actions": "その他の操作",
"move_to": "移動...",
"remove_color": "ラベルを削除",
"remove_tag": "ラベルを削除",
"tag_filter_placeholder": "ラベルを絞り込む",
"tag_no_matches": "一致するラベルがありません",
"more_count": "他{count}件",
"characters_count": "{count}文字",
"quick_reply_placeholder": "クイック返信を入力...",
@@ -397,17 +402,6 @@
"message_id": "メッセージID",
"list_info": "リスト情報"
},
"color_tag": {
"title": "カラータグ",
"red": "赤",
"orange": "オレンジ",
"yellow": "黄色",
"green": "緑",
"blue": "青",
"purple": "紫",
"pink": "ピンク",
"none": "なし"
},
"tooltips": {
"reply": "返信",
"reply_all": "全員に返信 (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "表示名",
"autocomplete_search_server": "サーバーを検索",
"autocomplete_searching": "検索中...",
"toolbar": {
"bold": "太字",
"italic": "斜体",
"underline": "下線",
"strikethrough": "取り消し線",
"text_color": "文字色",
"remove_color": "色を解除",
"heading_1": "見出し1",
"heading_2": "見出し2",
"bullet_list": "箇条書き",
"ordered_list": "番号付きリスト",
"quote": "引用",
"code_block": "コードブロック",
"align_left": "左揃え",
"align_center": "中央揃え",
"align_right": "右揃え",
"text_direction": "文字方向 (RTL/LTR)",
"link": "リンク",
"table": "表",
"clear_formatting": "書式をクリア",
"undo": "元に戻す",
"redo": "やり直す",
"add_row_above": "上に行を追加",
"add_row_below": "下に行を追加",
"add_column_before": "左に列を追加",
"add_column_after": "右に列を追加",
"delete_row": "行を削除",
"delete_column": "列を削除",
"toggle_header_row": "ヘッダー行の切り替え",
"delete_table": "表を削除",
"pick_size": "サイズを選択"
},
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "メールラベル",
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
"add_keyword": "ラベルを追加",
"reset_defaults": "デフォルトに戻す",
"label_field": "表示名",
"label_placeholder": "例:仕事、個人、緊急",
"id_field": "ラベルID",
@@ -996,7 +1021,22 @@
"add": "追加",
"cancel": "キャンセル",
"migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールのラベルの更新に失敗しました"
"migration_error": "既存のメールのラベルの更新に失敗しました",
"nesting": {
"label": "ネストされたラベル",
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
},
"parent_field": "親ラベル",
"no_parent": "親ラベルなし",
"too_long": "このラベルのパスが長すぎます(最大{max}文字)",
"has_children_locked": "このラベルの下に他のラベルがネストされているため、名前と親ラベルは変更できません。先に移動または削除してください。",
"has_children_delete": "先にこのラベルの下にネストされたラベルを削除してください",
"visibility_field": "サイドバーでの表示",
"visibility": {
"show": "表示する",
"unread": "未読がある場合に表示",
"hide": "表示しない"
}
},
"notifications": {
"test_sound": "通知音をテスト",
@@ -2001,8 +2041,7 @@
"delete": "削除",
"mark_as_spam": "迷惑メールを報告",
"not_spam": "迷惑メールでない",
"color_tag": "ラベル",
"remove_color": "ラベルを削除",
"tag": "ラベル",
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル",
@@ -2336,6 +2375,9 @@
"section_address_book": "ディレクトリ",
"select_address_book": "ディレクトリを選択...",
"section_identity": "名前と識別情報",
"contact_type": "連絡先の種類",
"type_person": "個人",
"type_organization": "組織",
"section_work": "職業と組織",
"prefix": "敬称",
"prefix_placeholder": "博士、氏",
@@ -2420,7 +2462,7 @@
"cancel": "キャンセル",
"creating": "作成中...",
"updating": "更新中...",
"name_required": "名前は必須です",
"name_required": "名、姓、または組織を入力してください",
"email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "초기화",
"demo_tour": "둘러보기",
"tags": "태그",
"show_all_tags": "전체 보기({count})",
"show_fewer_tags": "간략히 보기",
"folders": "폴더",
"mail": "메일",
"nav_label": "내비게이션",
@@ -298,6 +300,7 @@
"print": "인쇄",
"view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml 또는 .zip 가져오기",
"keyboard_shortcuts": "단축키 (?)",
"email_source": "메일 원본",
@@ -324,13 +327,15 @@
"view_contact": "연락처 보기",
"message_details": "메시지 상세 정보",
"more_reply_options": "답장 옵션 더보기",
"set_color": "태그 설정",
"set_tag": "태그 설정",
"tag": "태그",
"more_actions": "작업 더보기",
"previous": "이전",
"next": "다음",
"move_to": "이동...",
"remove_color": "태그 제거",
"remove_tag": "태그 제거",
"tag_filter_placeholder": "태그 검색",
"tag_no_matches": "일치하는 태그 없음",
"more_count": "+{count}개 더보기",
"characters_count": "{count}자",
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
@@ -399,17 +404,6 @@
"message_id": "메시지 ID",
"list_info": "목록 정보"
},
"color_tag": {
"title": "색상 태그",
"red": "빨간색",
"orange": "주황색",
"yellow": "노란색",
"green": "초록색",
"blue": "파란색",
"purple": "보라색",
"pink": "분홍색",
"none": "없음"
},
"tooltips": {
"reply": "답장 (r)",
"reply_all": "전체 답장 (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "표시 이름",
"autocomplete_search_server": "서버에서 검색",
"autocomplete_searching": "검색 중...",
"toolbar": {
"bold": "굵게",
"italic": "기울임꼴",
"underline": "밑줄",
"strikethrough": "취소선",
"text_color": "글자 색",
"remove_color": "색 제거",
"heading_1": "제목 1",
"heading_2": "제목 2",
"bullet_list": "글머리 기호 목록",
"ordered_list": "번호 매기기 목록",
"quote": "인용",
"code_block": "코드 블록",
"align_left": "왼쪽 정렬",
"align_center": "가운데 정렬",
"align_right": "오른쪽 정렬",
"text_direction": "텍스트 방향 (RTL/LTR)",
"link": "링크",
"table": "표",
"clear_formatting": "서식 지우기",
"undo": "실행 취소",
"redo": "다시 실행",
"add_row_above": "위에 행 추가",
"add_row_below": "아래에 행 추가",
"add_column_before": "앞에 열 추가",
"add_column_after": "뒤에 열 추가",
"delete_row": "행 삭제",
"delete_column": "열 삭제",
"toggle_header_row": "머리글 행 전환",
"delete_table": "표 삭제",
"pick_size": "크기 선택"
},
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "이메일 태그",
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
"add_keyword": "태그 추가",
"reset_defaults": "기본값으로 초기화",
"label_field": "표시 이름",
"label_placeholder": "예: 업무, 개인, 긴급",
"id_field": "태그 ID",
@@ -996,7 +1021,22 @@
"add": "추가",
"cancel": "취소",
"migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
"nesting": {
"label": "중첩 태그",
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
},
"parent_field": "상위 태그",
"no_parent": "상위 태그 없음",
"too_long": "이 태그 경로가 너무 깁니다(최대 {max}자)",
"has_children_locked": "이 태그 아래에 다른 태그가 중첩되어 있어 이름과 상위 태그가 잠겨 있습니다. 먼저 옮기거나 삭제하세요.",
"has_children_delete": "이 태그 아래에 중첩된 태그를 먼저 삭제하세요",
"visibility_field": "사이드바 표시",
"visibility": {
"show": "표시",
"unread": "읽지 않음이 있을 때 표시",
"hide": "숨기기"
}
},
"notifications": {
"test_sound": "알림음 테스트",
@@ -2001,8 +2041,7 @@
"delete": "삭제",
"mark_as_spam": "스팸 신고",
"not_spam": "정상 메일",
"color_tag": "태그",
"remove_color": "태그 제거",
"tag": "태그",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소",
@@ -2336,6 +2375,9 @@
"section_address_book": "디렉터리",
"select_address_book": "디렉터리 선택...",
"section_identity": "이름 및 신원",
"contact_type": "연락처 유형",
"type_person": "개인",
"type_organization": "소속(회사)",
"section_work": "직장 및 소속",
"prefix": "호칭",
"prefix_placeholder": "예: Dr., Mr., Mrs.",
@@ -2420,7 +2462,7 @@
"cancel": "취소",
"creating": "만드는 중...",
"updating": "업데이트 중...",
"name_required": "이름이나중에 하나는 꼭 필요해요",
"name_required": "이름,또는 조직을 입력하세요",
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
"email_error_inline": "이메일 형식이 잘못되었어요",
"save_failed": "연락처를 저장하지 못했어요",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Atiestatīt",
"demo_tour": "Ekskursija",
"tags": "Tagi",
"show_all_tags": "Rādīt visus ({count})",
"show_fewer_tags": "Rādīt mazāk",
"folders": "Mapes",
"mail": "Pasts",
"nav_label": "Navigācija",
@@ -298,6 +300,7 @@
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importēt .eml vai .zip",
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
"email_source": "Vēstules avota kods",
@@ -324,13 +327,15 @@
"view_contact": "Skatīt kontaktu",
"message_details": "Informācija par ziņojumu",
"more_reply_options": "Papildu atbildēšanas iespējas",
"set_color": "Iestatīt tagu",
"set_tag": "Iestatīt tagu",
"tag": "Tags",
"more_actions": "Citas darbības",
"previous": "Iepr.",
"next": "Nāk.",
"move_to": "Pārvietot uz...",
"remove_color": "Noņemt tagu",
"remove_tag": "Noņemt tagu",
"tag_filter_placeholder": "Filtrēt tagus",
"tag_no_matches": "Nav atbilstošu tagu",
"more_count": "+vairāk {count}",
"characters_count": "{count} rakstzīmes",
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
@@ -399,17 +404,6 @@
"message_id": "Ziņojuma ID",
"list_info": "Informācija par adresātu sarakstu"
},
"color_tag": {
"title": "Krāsu tags",
"red": "Sarkans",
"orange": "Oranžs",
"yellow": "Dzeltens",
"green": "Zaļš",
"blue": "Zils",
"purple": "Violets",
"pink": "Rozā",
"none": "Nav"
},
"tooltips": {
"reply": "Atbildēt (r)",
"reply_all": "Atbildēt visiem (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Parādāmais vārds",
"autocomplete_search_server": "Meklēt serverī",
"autocomplete_searching": "Meklē...",
"toolbar": {
"bold": "Treknraksts",
"italic": "Kursīvs",
"underline": "Pasvītrots",
"strikethrough": "Pārsvītrots",
"text_color": "Teksta krāsa",
"remove_color": "Noņemt krāsu",
"heading_1": "Virsraksts 1",
"heading_2": "Virsraksts 2",
"bullet_list": "Aizzīmju saraksts",
"ordered_list": "Numurēts saraksts",
"quote": "Citāts",
"code_block": "Koda bloks",
"align_left": "Līdzināt pa kreisi",
"align_center": "Centrēt",
"align_right": "Līdzināt pa labi",
"text_direction": "Teksta virziens (RTL/LTR)",
"link": "Saite",
"table": "Tabula",
"clear_formatting": "Notīrīt formatējumu",
"undo": "Atsaukt",
"redo": "Atkārtot",
"add_row_above": "Pievienot rindu virs",
"add_row_below": "Pievienot rindu zem",
"add_column_before": "Pievienot kolonnu pirms",
"add_column_after": "Pievienot kolonnu pēc",
"delete_row": "Dzēst rindu",
"delete_column": "Dzēst kolonnu",
"toggle_header_row": "Pārslēgt galvenes rindu",
"delete_table": "Dzēst tabulu",
"pick_size": "Izvēlēties izmēru"
},
"send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "E-pasta tagi",
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
"add_keyword": "Pievienot tagu",
"reset_defaults": "Atiestatīt noklusējumu",
"label_field": "Redzamais nosaukums",
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
"id_field": "Taga identifikators",
@@ -996,7 +1021,22 @@
"add": "Pievienot",
"cancel": "Atcelt",
"migrating": "Taga atjaunināšana esošajos e-pastos…",
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos",
"nesting": {
"label": "Ligzdoti tagi",
"description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku."
},
"parent_field": "Vecāktags",
"no_parent": "Nav vecāktaga",
"too_long": "Šis taga ceļš ir pārāk garš (ne vairāk kā {max} rakstzīmes)",
"has_children_locked": "Zem šī taga ir ligzdoti citi tagi, tāpēc tā nosaukums un vecāktags ir bloķēti. Vispirms pārvietojiet vai noņemiet tos.",
"has_children_delete": "Vispirms noņemiet zem šī ligzdotos tagus",
"visibility_field": "Redzamība sānjoslā",
"visibility": {
"show": "Rādīt",
"unread": "Rādīt, ja ir nelasīti",
"hide": "Slēpt"
}
},
"notifications": {
"test_sound": "Pārbaudīt paziņojuma skaņu",
@@ -2001,8 +2041,7 @@
"delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli",
"not_spam": "Nav mēstule",
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"tag": "Tags",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu",
@@ -2332,6 +2371,9 @@
"section_address_book": "Katalogs",
"select_address_book": "Izvēlieties katalogu...",
"section_identity": "Vārds un identitāte",
"contact_type": "Kontakta veids",
"type_person": "Persona",
"type_organization": "Organizācija",
"section_work": "Darbs un organizācija",
"prefix": "Prefikss",
"prefix_placeholder": "Dr., kungs, kundze",
@@ -2416,7 +2458,7 @@
"cancel": "Atcelt",
"creating": "Izveido...",
"updating": "Atjaunina...",
"name_required": "Nepieciešams vismaz vārds vai uzvārds",
"name_required": "Ievadiet vārdu, uzvārdu vai organizāciju",
"email_invalid": "Ievadiet derīgu e-pasta adresi",
"email_error_inline": "Nederīgs e-pasta formāts",
"save_failed": "Neizdevās saglabāt kontaktu",
+61 -19
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetten",
"demo_tour": "Rondleiding",
"tags": "Labels",
"show_all_tags": "Alles tonen ({count})",
"show_fewer_tags": "Minder tonen",
"folders": "Mappen",
"mail": "E-mail",
"nav_label": "Navigatie",
@@ -298,6 +300,7 @@
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml of .zip importeren",
"keyboard_shortcuts": "Sneltoetsen (?)",
"email_source": "E-mailbron",
@@ -324,11 +327,13 @@
"view_contact": "Contact bekijken",
"message_details": "Berichtdetails",
"more_reply_options": "Meer antwoordopties",
"set_color": "Label instellen",
"set_tag": "Label instellen",
"tag": "Label",
"more_actions": "Meer acties",
"move_to": "Verplaatsen naar...",
"remove_color": "Label verwijderen",
"remove_tag": "Label verwijderen",
"tag_filter_placeholder": "Labels filteren",
"tag_no_matches": "Geen overeenkomende labels",
"more_count": "+{count} meer",
"characters_count": "{count} tekens",
"quick_reply_placeholder": "Schrijf een snel antwoord...",
@@ -397,17 +402,6 @@
"message_id": "Bericht-ID",
"list_info": "Lijstinformatie"
},
"color_tag": {
"title": "Kleurtag",
"red": "Rood",
"orange": "Oranje",
"yellow": "Geel",
"green": "Groen",
"blue": "Blauw",
"purple": "Paars",
"pink": "Roze",
"none": "Geen"
},
"tooltips": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Weergavenaam",
"autocomplete_search_server": "Op de server zoeken",
"autocomplete_searching": "Bezig met zoeken...",
"toolbar": {
"bold": "Vet",
"italic": "Cursief",
"underline": "Onderstrepen",
"strikethrough": "Doorhalen",
"text_color": "Tekstkleur",
"remove_color": "Kleur verwijderen",
"heading_1": "Kop 1",
"heading_2": "Kop 2",
"bullet_list": "Opsommingslijst",
"ordered_list": "Genummerde lijst",
"quote": "Citaat",
"code_block": "Codeblok",
"align_left": "Links uitlijnen",
"align_center": "Centreren",
"align_right": "Rechts uitlijnen",
"text_direction": "Tekstrichting (RTL/LTR)",
"link": "Link",
"table": "Tabel",
"clear_formatting": "Opmaak wissen",
"undo": "Ongedaan maken",
"redo": "Opnieuw",
"add_row_above": "Rij erboven toevoegen",
"add_row_below": "Rij eronder toevoegen",
"add_column_before": "Kolom ervoor toevoegen",
"add_column_after": "Kolom erna toevoegen",
"delete_row": "Rij verwijderen",
"delete_column": "Kolom verwijderen",
"toggle_header_row": "Koprij aan/uit",
"delete_table": "Tabel verwijderen",
"pick_size": "Grootte kiezen"
},
"send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan."
},
"confirm_dialog": {
@@ -981,9 +1007,8 @@
},
"keywords": {
"title": "E-maillabels",
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"add_keyword": "Label toevoegen",
"reset_defaults": "Standaardwaarden herstellen",
"label_field": "Weergavenaam",
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
"id_field": "Label-ID",
@@ -996,7 +1021,22 @@
"add": "Toevoegen",
"cancel": "Annuleren",
"migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
"nesting": {
"label": "Geneste labels",
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
},
"parent_field": "Bovenliggend label",
"no_parent": "Geen bovenliggend label",
"too_long": "Dit labelpad is te lang (maximaal {max} tekens)",
"has_children_locked": "Er vallen andere labels onder dit label, dus de naam en het bovenliggende label liggen vast. Verplaats of verwijder ze eerst.",
"has_children_delete": "Verwijder eerst de labels die hieronder vallen",
"visibility_field": "Zichtbaarheid in de zijbalk",
"visibility": {
"show": "Tonen",
"unread": "Tonen bij ongelezen",
"hide": "Verbergen"
}
},
"notifications": {
"test_sound": "Meldingsgeluid testen",
@@ -2001,8 +2041,7 @@
"delete": "Verwijderen",
"mark_as_spam": "Spam melden",
"not_spam": "Geen spam",
"color_tag": "Label",
"remove_color": "Label verwijderen",
"tag": "Label",
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren",
@@ -2336,6 +2375,9 @@
"section_address_book": "Adresboek",
"select_address_book": "Selecteer een adresboek...",
"section_identity": "Naam en identiteit",
"contact_type": "Contacttype",
"type_person": "Persoon",
"type_organization": "Organisatie",
"section_work": "Werk en organisatie",
"prefix": "Voorvoegsel",
"prefix_placeholder": "Dr., Dhr., Mevr.",
@@ -2420,7 +2462,7 @@
"cancel": "Annuleren",
"creating": "Aanmaken...",
"updating": "Bijwerken...",
"name_required": "Ten minste een voor- of achternaam is vereist",
"name_required": "Voer een voornaam, achternaam of organisatie in",
"email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetuj",
"demo_tour": "Przewodnik",
"tags": "Etykiety",
"show_all_tags": "Pokaż wszystkie ({count})",
"show_fewer_tags": "Pokaż mniej",
"folders": "Foldery",
"mail": "Poczta",
"nav_label": "Nawigacja",
@@ -298,6 +300,7 @@
"print": "Drukuj",
"view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importuj .eml lub .zip",
"keyboard_shortcuts": "Skróty klawiszowe (?)",
"email_source": "Źródło wiadomości",
@@ -324,13 +327,15 @@
"view_contact": "Pokaż kontakt",
"message_details": "Szczegóły wiadomości",
"more_reply_options": "Więcej opcji odpowiedzi",
"set_color": "Ustaw etykietę",
"set_tag": "Ustaw etykietę",
"tag": "Etykieta",
"more_actions": "Więcej działań",
"previous": "Poprz.",
"next": "Nast.",
"move_to": "Przenieś do...",
"remove_color": "Usuń etykietę",
"remove_tag": "Usuń etykietę",
"tag_filter_placeholder": "Filtruj etykiety",
"tag_no_matches": "Brak pasujących etykiet",
"more_count": "+{count} więcej",
"characters_count": "{count} znaków",
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
@@ -399,17 +404,6 @@
"message_id": "ID wiadomości",
"list_info": "Informacje o liście"
},
"color_tag": {
"title": "Kolorowa etykieta",
"red": "Czerwony",
"orange": "Pomarańczowy",
"yellow": "Żółty",
"green": "Zielony",
"blue": "Niebieski",
"purple": "Fioletowy",
"pink": "Różowy",
"none": "Brak"
},
"tooltips": {
"reply": "Odpowiedz (r)",
"reply_all": "Odpowiedz wszystkim (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Wyświetlana nazwa",
"autocomplete_search_server": "Szukaj na serwerze",
"autocomplete_searching": "Wyszukiwanie...",
"toolbar": {
"bold": "Pogrubienie",
"italic": "Kursywa",
"underline": "Podkreślenie",
"strikethrough": "Przekreślenie",
"text_color": "Kolor tekstu",
"remove_color": "Usuń kolor",
"heading_1": "Nagłówek 1",
"heading_2": "Nagłówek 2",
"bullet_list": "Lista punktowana",
"ordered_list": "Lista numerowana",
"quote": "Cytat",
"code_block": "Blok kodu",
"align_left": "Wyrównaj do lewej",
"align_center": "Wyśrodkuj",
"align_right": "Wyrównaj do prawej",
"text_direction": "Kierunek tekstu (RTL/LTR)",
"link": "Link",
"table": "Tabela",
"clear_formatting": "Wyczyść formatowanie",
"undo": "Cofnij",
"redo": "Ponów",
"add_row_above": "Dodaj wiersz powyżej",
"add_row_below": "Dodaj wiersz poniżej",
"add_column_before": "Dodaj kolumnę przed",
"add_column_after": "Dodaj kolumnę po",
"delete_row": "Usuń wiersz",
"delete_column": "Usuń kolumnę",
"toggle_header_row": "Przełącz wiersz nagłówka",
"delete_table": "Usuń tabelę",
"pick_size": "Wybierz rozmiar"
},
"send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Etykiety e-mail",
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
"add_keyword": "Dodaj etykietę",
"reset_defaults": "Przywróć domyślne",
"label_field": "Nazwa wyświetlana",
"label_placeholder": "np. Praca, Osobiste, Pilne",
"id_field": "ID etykiety",
@@ -996,7 +1021,22 @@
"add": "Dodaj",
"cancel": "Anuluj",
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach",
"nesting": {
"label": "Zagnieżdżone etykiety",
"description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo."
},
"parent_field": "Etykieta nadrzędna",
"no_parent": "Brak etykiety nadrzędnej",
"too_long": "Ta ścieżka etykiety jest za długa (maksymalnie {max} znaków)",
"has_children_locked": "Pod tą etykietą zagnieżdżone są inne etykiety, więc jej nazwa i etykieta nadrzędna są zablokowane. Najpierw je przenieś lub usuń.",
"has_children_delete": "Najpierw usuń etykiety zagnieżdżone pod tą",
"visibility_field": "Widoczność w panelu bocznym",
"visibility": {
"show": "Pokaż",
"unread": "Pokaż przy nieprzeczytanych",
"hide": "Ukryj"
}
},
"notifications": {
"test_sound": "Przetestuj dźwięk powiadomienia",
@@ -2001,8 +2041,7 @@
"delete": "Usuń",
"mark_as_spam": "Zgłoś spam",
"not_spam": "To nie spam",
"color_tag": "Etykieta",
"remove_color": "Usuń etykietę",
"tag": "Etykieta",
"items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę",
@@ -2336,6 +2375,9 @@
"section_address_book": "Katalog",
"select_address_book": "Wybierz katalog...",
"section_identity": "Imię i tożsamość",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
"type_organization": "Organizacja",
"section_work": "Praca i organizacja",
"prefix": "Tytuł",
"prefix_placeholder": "Dr, Pan, Pani",
@@ -2420,7 +2462,7 @@
"cancel": "Anuluj",
"creating": "Tworzenie...",
"updating": "Aktualizowanie...",
"name_required": "Wymagane jest przynajmniej imię lub nazwisko",
"name_required": "Podaj imię, nazwisko lub organizację",
"email_invalid": "Wprowadź prawidłowy adres e-mail",
"email_error_inline": "Nieprawidłowy format adresu e-mail",
"save_failed": "Nie udało się zapisać kontaktu",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Repor",
"demo_tour": "Tour",
"tags": "Etiquetas",
"show_all_tags": "Mostrar tudo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Pastas",
"mail": "E-mail",
"nav_label": "Navegação",
@@ -298,6 +300,7 @@
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importar .eml ou .zip",
"keyboard_shortcuts": "Atalhos de teclado (?)",
"email_source": "Código-fonte do E-mail",
@@ -324,11 +327,13 @@
"view_contact": "Ver contato",
"message_details": "Detalhes da Mensagem",
"more_reply_options": "Mais opções de resposta",
"set_color": "Definir etiqueta",
"set_tag": "Definir etiqueta",
"tag": "Etiqueta",
"more_actions": "Mais ações",
"move_to": "Mover para...",
"remove_color": "Remover etiqueta",
"remove_tag": "Remover etiqueta",
"tag_filter_placeholder": "Filtrar etiquetas",
"tag_no_matches": "Nenhuma etiqueta correspondente",
"more_count": "+{count} mais",
"characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escreva uma resposta rápida...",
@@ -397,17 +402,6 @@
"message_id": "ID da Mensagem",
"list_info": "Informações da Lista"
},
"color_tag": {
"title": "Etiqueta de Cor",
"red": "Vermelho",
"orange": "Laranja",
"yellow": "Amarelo",
"green": "Verde",
"blue": "Azul",
"purple": "Roxo",
"pink": "Rosa",
"none": "Nenhuma"
},
"tooltips": {
"reply": "Responder",
"reply_all": "Responder a todos (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Nome de exibição",
"autocomplete_search_server": "Pesquisar no servidor",
"autocomplete_searching": "Pesquisando...",
"toolbar": {
"bold": "Negrito",
"italic": "Itálico",
"underline": "Sublinhado",
"strikethrough": "Tachado",
"text_color": "Cor do texto",
"remove_color": "Remover cor",
"heading_1": "Título 1",
"heading_2": "Título 2",
"bullet_list": "Lista com marcadores",
"ordered_list": "Lista numerada",
"quote": "Citação",
"code_block": "Bloco de código",
"align_left": "Alinhar à esquerda",
"align_center": "Centralizar",
"align_right": "Alinhar à direita",
"text_direction": "Direção do texto (RTL/LTR)",
"link": "Link",
"table": "Tabela",
"clear_formatting": "Limpar formatação",
"undo": "Desfazer",
"redo": "Refazer",
"add_row_above": "Adicionar linha acima",
"add_row_below": "Adicionar linha abaixo",
"add_column_before": "Adicionar coluna antes",
"add_column_after": "Adicionar coluna depois",
"delete_row": "Excluir linha",
"delete_column": "Excluir coluna",
"toggle_header_row": "Alternar linha de cabeçalho",
"delete_table": "Excluir tabela",
"pick_size": "Escolher tamanho"
},
"send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Etiquetas de e-mail",
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
"add_keyword": "Adicionar etiqueta",
"reset_defaults": "Restaurar padrões",
"label_field": "Nome de exibição",
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
"id_field": "ID da etiqueta",
@@ -996,7 +1021,22 @@
"add": "Adicionar",
"cancel": "Cancelar",
"migrating": "A atualizar etiqueta nos e-mails existentes…",
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes",
"nesting": {
"label": "Etiquetas aninhadas",
"description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral."
},
"parent_field": "Etiqueta principal",
"no_parent": "Sem etiqueta principal",
"too_long": "Este caminho de etiqueta é demasiado longo (no máximo {max} caracteres)",
"has_children_locked": "Existem outras etiquetas aninhadas sob esta, por isso o seu nome e a sua etiqueta principal estão bloqueados. Mova-as ou remova-as primeiro.",
"has_children_delete": "Remova primeiro as etiquetas aninhadas sob esta",
"visibility_field": "Visibilidade na barra lateral",
"visibility": {
"show": "Mostrar",
"unread": "Mostrar se não lidas",
"hide": "Ocultar"
}
},
"notifications": {
"test_sound": "Testar som de notificação",
@@ -2001,8 +2041,7 @@
"delete": "Excluir",
"mark_as_spam": "Reportar spam",
"not_spam": "Não é spam",
"color_tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"tag": "Etiqueta",
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio",
@@ -2336,6 +2375,9 @@
"section_address_book": "Diretório",
"select_address_book": "Selecionar um diretório...",
"section_identity": "Nome e identidade",
"contact_type": "Tipo de contacto",
"type_person": "Pessoa",
"type_organization": "Organização",
"section_work": "Trabalho e organização",
"prefix": "Prefixo",
"prefix_placeholder": "Dr., Sr., Sra.",
@@ -2420,7 +2462,7 @@
"cancel": "Cancelar",
"creating": "Criando...",
"updating": "Atualizando...",
"name_required": "É necessário pelo menos um nome ou sobrenome",
"name_required": "Introduza um nome próprio, apelido ou organização",
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetare",
"demo_tour": "Tur de prezentare",
"tags": "Etichete",
"show_all_tags": "Afișează tot ({count})",
"show_fewer_tags": "Afișează mai puține",
"folders": "Dosare",
"shared": "Partajat",
"mail": "E-mail",
@@ -298,6 +300,7 @@
"print": "Imprimare",
"view_source": "Vizualizați sursa",
"export_email": "Exportați ca fișier .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importați fișiere .eml sau .zip",
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
"email_source": "Sursa e-mailului",
@@ -324,13 +327,15 @@
"view_contact": "Vizualizare contact",
"message_details": "Detalii mesaj",
"more_reply_options": "Mai multe opțiuni de răspuns",
"set_color": "Setați eticheta",
"set_tag": "Setați eticheta",
"tag": "Etichetă",
"more_actions": "Alte acțiuni",
"previous": "Anterior",
"next": "Următorul",
"move_to": "Mergi la...",
"remove_color": "Eliminați eticheta",
"remove_tag": "Eliminați eticheta",
"tag_filter_placeholder": "Filtrează etichetele",
"tag_no_matches": "Nicio etichetă corespunzătoare",
"more_count": "+{count} mai multe",
"characters_count": "{count} caractere",
"quick_reply_placeholder": "Scrie un răspuns rapid...",
@@ -424,17 +429,6 @@
"message_id": "IDul mesajelor",
"list_info": "Informații despre listă"
},
"color_tag": {
"title": "Etichetă de culoare",
"red": "Roșu",
"orange": "Portocaliu",
"yellow": "Galben",
"green": "Verde",
"blue": "Albastru",
"purple": "Violet",
"pink": "Roz",
"none": "Niciunul"
},
"tooltips": {
"reply": "Răspunde (r)",
"reply_all": "Răspunde tuturor (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Numele afișat",
"autocomplete_search_server": "Caută pe server",
"autocomplete_searching": "Se caută...",
"toolbar": {
"bold": "Aldin",
"italic": "Cursiv",
"underline": "Subliniat",
"strikethrough": "Tăiat",
"text_color": "Culoarea textului",
"remove_color": "Elimină culoarea",
"heading_1": "Titlu 1",
"heading_2": "Titlu 2",
"bullet_list": "Listă cu marcatori",
"ordered_list": "Listă numerotată",
"quote": "Citat",
"code_block": "Bloc de cod",
"align_left": "Aliniere la stânga",
"align_center": "Centrare",
"align_right": "Aliniere la dreapta",
"text_direction": "Direcția textului (RTL/LTR)",
"link": "Link",
"table": "Tabel",
"clear_formatting": "Șterge formatarea",
"undo": "Anulează",
"redo": "Refă",
"add_row_above": "Adaugă rând deasupra",
"add_row_below": "Adaugă rând dedesubt",
"add_column_before": "Adaugă coloană înainte",
"add_column_after": "Adaugă coloană după",
"delete_row": "Șterge rândul",
"delete_column": "Șterge coloana",
"toggle_header_row": "Comută rândul de antet",
"delete_table": "Șterge tabelul",
"pick_size": "Alege dimensiunea"
},
"send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "Etichete de e-mail",
"description": "Definiți etichete pentru a vă organiza e-mailurile pe culori. Acestea sunt stocate pe server sub formă de cuvinte-cheie dJMAP.",
"add_keyword": "Adăugați etichetă",
"reset_defaults": "Resezare la setările implicite",
"label_field": "Nume afișat",
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
"id_field": "EtichetăID",
@@ -999,7 +1024,22 @@
"add": "Adăugați",
"cancel": "Anulează",
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente"
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente",
"nesting": {
"label": "Etichete imbricate",
"description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală."
},
"parent_field": "Etichetă părinte",
"no_parent": "Fără etichetă părinte",
"too_long": "Această cale de etichetă este prea lungă (cel mult {max} caractere)",
"has_children_locked": "Alte etichete sunt imbricate sub aceasta, așa că numele și eticheta părinte sunt blocate. Mutați-le sau eliminați-le mai întâi.",
"has_children_delete": "Eliminați mai întâi etichetele imbricate sub aceasta",
"visibility_field": "Vizibilitate în bara laterală",
"visibility": {
"show": "Afișează",
"unread": "Afișează dacă sunt necitite",
"hide": "Ascunde"
}
},
"notifications": {
"test_sound": "Testați sunetul de notificare",
@@ -2001,8 +2041,7 @@
"delete": "Șterge",
"mark_as_spam": "Raportează spamul",
"not_spam": "Nu este spam",
"color_tag": "Etichetă",
"remove_color": "Eliminați eticheta",
"tag": "Etichetă",
"items_selected": "{count} e-mailuri selectate",
"edit_draft": "Editează schița",
"cancel_scheduled_send": "Anulează trimiterea",
@@ -2337,6 +2376,9 @@
"section_address_book": "Director",
"select_address_book": "Selectați un director...",
"section_identity": "Nume și identitate",
"contact_type": "Tip de contact",
"type_person": "Persoană",
"type_organization": "Organizare",
"section_work": "Muncă și organizare",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Dl., Dna.",
@@ -2421,7 +2463,7 @@
"cancel": "Anulează",
"creating": "Se creează...",
"updating": "Se actualizează...",
"name_required": "Este necesar cel puțin un prenume sau un nume de familie",
"name_required": "Introduceți un prenume, un nume sau o organizație",
"email_invalid": "Vă rugăm să introduceți o adresă de e-mail validă",
"email_error_inline": "Format de e-mail nevalid",
"save_failed": "Nu s-a putut salva contactul",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Сбросить",
"demo_tour": "Тур",
"tags": "Теги",
"show_all_tags": "Показать все ({count})",
"show_fewer_tags": "Показать меньше",
"folders": "Папки",
"mail": "Почта",
"nav_label": "Навигация",
@@ -298,6 +300,7 @@
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Импортировать .eml или .zip",
"keyboard_shortcuts": "Сочетания клавиш (?)",
"email_source": "Исходный код письма",
@@ -324,13 +327,15 @@
"view_contact": "Просмотреть контакт",
"message_details": "Детали сообщения",
"more_reply_options": "Дополнительные параметры ответа",
"set_color": "Установить тег",
"set_tag": "Установить тег",
"tag": "Тег",
"more_actions": "Другие действия",
"previous": "Пред.",
"next": "След.",
"move_to": "Переместить в...",
"remove_color": "Удалить тег",
"remove_tag": "Удалить тег",
"tag_filter_placeholder": "Фильтр тегов",
"tag_no_matches": "Подходящих тегов нет",
"more_count": "+{count} ещё",
"characters_count": "{count} символов",
"quick_reply_placeholder": "Написать быстрый ответ...",
@@ -399,17 +404,6 @@
"message_id": "Идентификатор сообщения",
"list_info": "Информация о рассылке"
},
"color_tag": {
"title": "Цветной тег",
"red": "Красный",
"orange": "Оранжевый",
"yellow": "Жёлтый",
"green": "Зелёный",
"blue": "Синий",
"purple": "Фиолетовый",
"pink": "Розовый",
"none": "Нет"
},
"tooltips": {
"reply": "Ответить (r)",
"reply_all": "Ответить всем (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Отображаемое имя",
"autocomplete_search_server": "Искать на сервере",
"autocomplete_searching": "Поиск...",
"toolbar": {
"bold": "Жирный",
"italic": "Курсив",
"underline": "Подчёркнутый",
"strikethrough": "Зачёркнутый",
"text_color": "Цвет текста",
"remove_color": "Убрать цвет",
"heading_1": "Заголовок 1",
"heading_2": "Заголовок 2",
"bullet_list": "Маркированный список",
"ordered_list": "Нумерованный список",
"quote": "Цитата",
"code_block": "Блок кода",
"align_left": "По левому краю",
"align_center": "По центру",
"align_right": "По правому краю",
"text_direction": "Направление текста (RTL/LTR)",
"link": "Ссылка",
"table": "Таблица",
"clear_formatting": "Очистить форматирование",
"undo": "Отменить",
"redo": "Повторить",
"add_row_above": "Вставить строку выше",
"add_row_below": "Вставить строку ниже",
"add_column_before": "Вставить столбец слева",
"add_column_after": "Вставить столбец справа",
"delete_row": "Удалить строку",
"delete_column": "Удалить столбец",
"toggle_header_row": "Переключить строку заголовка",
"delete_table": "Удалить таблицу",
"pick_size": "Выбрать размер"
},
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Теги электронной почты",
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить тег",
"reset_defaults": "Сбросить по умолчанию",
"label_field": "Отображаемое название",
"label_placeholder": "напр., Работа, Личное, Срочно",
"id_field": "Идентификатор тега",
@@ -996,7 +1021,22 @@
"add": "Добавить",
"cancel": "Отмена",
"migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить тег в существующих письмах"
"migration_error": "Не удалось обновить тег в существующих письмах",
"nesting": {
"label": "Вложенные теги",
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
},
"parent_field": "Родительский тег",
"no_parent": "Без родительского тега",
"too_long": "Этот путь тега слишком длинный (не более {max} символов)",
"has_children_locked": "В этот тег вложены другие теги, поэтому его имя и родительский тег заблокированы. Сначала переместите или удалите их.",
"has_children_delete": "Сначала удалите теги, вложенные в этот",
"visibility_field": "Видимость в боковой панели",
"visibility": {
"show": "Показывать",
"unread": "Показывать при непрочитанных",
"hide": "Скрывать"
}
},
"notifications": {
"test_sound": "Проверить звук уведомления",
@@ -2001,8 +2041,7 @@
"delete": "Удалить",
"mark_as_spam": "Отметить как спам",
"not_spam": "Не спам",
"color_tag": "Тег",
"remove_color": "Удалить тег",
"tag": "Тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку",
@@ -2336,6 +2375,9 @@
"section_address_book": "Каталог",
"select_address_book": "Выберите каталог...",
"section_identity": "Имя и личность",
"contact_type": "Тип контакта",
"type_person": "Человек",
"type_organization": "Организация",
"section_work": "Работа и организация",
"prefix": "Префикс",
"prefix_placeholder": "Д-р., Г-н., Г-жа.",
@@ -2420,7 +2462,7 @@
"cancel": "Отмена",
"creating": "Создание...",
"updating": "Обновление...",
"name_required": "Требуется хотя бы имя или фамилия",
"name_required": "Укажите имя, фамилию или организацию",
"email_invalid": "Введите корректный адрес электронной почты",
"email_error_inline": "Неверный формат email",
"save_failed": "Не удалось сохранить контакт",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetovať",
"demo_tour": "Sprievodca",
"tags": "Štítky",
"show_all_tags": "Zobraziť všetko ({count})",
"show_fewer_tags": "Zobraziť menej",
"folders": "Priečinky",
"shared": "Zdieľané",
"mail": "Pošta",
@@ -298,6 +300,7 @@
"print": "Tlačiť",
"view_source": "Zobraziť zdrojový kód",
"export_email": "Exportovať ako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importovať .eml alebo .zip",
"keyboard_shortcuts": "Klávesové skratky (?)",
"email_source": "Zdrojový kód e-mailu",
@@ -324,13 +327,15 @@
"view_contact": "Zobraziť kontakt",
"message_details": "Podrobnosti správy",
"more_reply_options": "Viac možností odpovede",
"set_color": "Nastaviť štítok",
"set_tag": "Nastaviť štítok",
"tag": "Štítok",
"more_actions": "Viac akcií",
"previous": "Predchádzajúci",
"next": "Ďalší",
"move_to": "Presunúť do...",
"remove_color": "Odstrániť štítok",
"remove_tag": "Odstrániť štítok",
"tag_filter_placeholder": "Filtrovať štítky",
"tag_no_matches": "Žiadne zodpovedajúce štítky",
"more_count": "+{count} ďalších",
"characters_count": "{count} znakov",
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
@@ -424,17 +429,6 @@
"message_id": "ID správy",
"list_info": "Informácie o zozname"
},
"color_tag": {
"title": "Farebný štítok",
"red": "Červený",
"orange": "Oranžový",
"yellow": "Žltý",
"green": "Zelený",
"blue": "Modrý",
"purple": "Fialový",
"pink": "RŪžový",
"none": "Žiadny"
},
"tooltips": {
"reply": "Odpovedať (r)",
"reply_all": "Odpovedať všetkým (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Zobrazené meno",
"autocomplete_search_server": "Hľadať na serveri",
"autocomplete_searching": "Hľadanie...",
"toolbar": {
"bold": "Tučné",
"italic": "Kurzíva",
"underline": "Podčiarknuté",
"strikethrough": "Prečiarknuté",
"text_color": "Farba textu",
"remove_color": "Odstrániť farbu",
"heading_1": "Nadpis 1",
"heading_2": "Nadpis 2",
"bullet_list": "Odrážkový zoznam",
"ordered_list": "Číslovaný zoznam",
"quote": "Citát",
"code_block": "Blok kódu",
"align_left": "Zarovnať doľava",
"align_center": "Na stred",
"align_right": "Zarovnať doprava",
"text_direction": "Smer textu (RTL/LTR)",
"link": "Odkaz",
"table": "Tabuľka",
"clear_formatting": "Vymazať formátovanie",
"undo": "Späť",
"redo": "Znova",
"add_row_above": "Pridať riadok nad",
"add_row_below": "Pridať riadok pod",
"add_column_before": "Pridať stĺpec pred",
"add_column_after": "Pridať stĺpec za",
"delete_row": "Odstrániť riadok",
"delete_column": "Odstrániť stĺpec",
"toggle_header_row": "Prepnúť riadok záhlavia",
"delete_table": "Odstrániť tabuľku",
"pick_size": "Vybrať veľkosť"
},
"send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "E-mailové štítky",
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
"add_keyword": "Pridať štítok",
"reset_defaults": "Obnoviť predvolené",
"label_field": "Zobrazovaný názov",
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
"id_field": "ID štítku",
@@ -999,7 +1024,22 @@
"add": "Pridať",
"cancel": "Zrušiť",
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch"
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch",
"nesting": {
"label": "Vnorené štítky",
"description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom."
},
"parent_field": "Nadradený štítok",
"no_parent": "Bez nadradeného štítku",
"too_long": "Táto cesta štítku je príliš dlhá (najviac {max} znakov)",
"has_children_locked": "Pod týmto štítkom sú vnorené ďalšie štítky, preto sú jeho názov a nadradený štítok uzamknuté. Najprv ich presuňte alebo odstráňte.",
"has_children_delete": "Najprv odstráňte štítky vnorené pod týmto",
"visibility_field": "Viditeľnosť v bočnom paneli",
"visibility": {
"show": "Zobraziť",
"unread": "Zobraziť pri neprečítaných",
"hide": "Skryť"
}
},
"notifications": {
"test_sound": "Otestovať zvuk oznámenia",
@@ -2001,8 +2041,7 @@
"delete": "Odstrániť",
"mark_as_spam": "Nahlásiť spam",
"not_spam": "Nie je spam",
"color_tag": "Štítok",
"remove_color": "Odstrániť štítok",
"tag": "Štítok",
"items_selected": "{count} vybraných e-mailov",
"edit_draft": "Upraviť koncept",
"cancel_scheduled_send": "Zrušiť odoslanie",
@@ -2337,6 +2376,9 @@
"section_address_book": "Adresár",
"select_address_book": "Vyberte adresár...",
"section_identity": "Meno a identita",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
"type_organization": "Organizácia",
"section_work": "Práca a organizácia",
"prefix": "Titul",
"prefix_placeholder": "Dr., Pán, Pani",
@@ -2421,7 +2463,7 @@
"cancel": "Zrušiť",
"creating": "Vytváranie...",
"updating": "Aktualizovanie...",
"name_required": "Je potrebné aspoň meno alebo priezvisko",
"name_required": "Zadajte meno, priezvisko alebo organizáciu",
"email_invalid": "Zadajte platnú e-mailovú adresu",
"email_error_inline": "Neplatný formát e-mailovej adresy",
"save_failed": "Uloženie kontaktu zlyhalo",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Sıfırla",
"demo_tour": "Tur",
"tags": "Etiketler",
"show_all_tags": "Tümünü göster ({count})",
"show_fewer_tags": "Daha az göster",
"folders": "Klasörler",
"shared": "Paylaşılan",
"mail": "Posta",
@@ -298,6 +300,7 @@
"print": "Yazdır",
"view_source": "Kaynağı görüntüle",
"export_email": ".eml olarak dışa aktar",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml veya .zip içe aktar",
"keyboard_shortcuts": "Klavye kısayolları (?)",
"email_source": "E-posta Kaynağı",
@@ -324,13 +327,15 @@
"view_contact": "Kişiyi görüntüle",
"message_details": "İleti Ayrıntıları",
"more_reply_options": "Daha fazla yanıt seçeneği",
"set_color": "Etiket ayarla",
"set_tag": "Etiket ayarla",
"tag": "Etiket",
"more_actions": "Diğer işlemler",
"previous": "Önceki",
"next": "Sonraki",
"move_to": "Şuraya taşı...",
"remove_color": "Etiketi kaldır",
"remove_tag": "Etiketi kaldır",
"tag_filter_placeholder": "Etiketleri filtrele",
"tag_no_matches": "Eşleşen etiket yok",
"more_count": "+{count} daha",
"characters_count": "{count} karakter",
"quick_reply_placeholder": "Hızlı yanıt yazın...",
@@ -399,17 +404,6 @@
"message_id": "İleti Kimliği",
"list_info": "Liste Bilgisi"
},
"color_tag": {
"title": "Renk Etiketi",
"red": "Kırmızı",
"orange": "Turuncu",
"yellow": "Sarı",
"green": "Yeşil",
"blue": "Mavi",
"purple": "Mor",
"pink": "Pembe",
"none": "Yok"
},
"tooltips": {
"reply": "Yanıtla (r)",
"reply_all": "Tümünü Yanıtla (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Görünen ad",
"autocomplete_search_server": "Sunucuda ara",
"autocomplete_searching": "Aranıyor...",
"toolbar": {
"bold": "Kalın",
"italic": "İtalik",
"underline": "Altı çizili",
"strikethrough": "Üstü çizili",
"text_color": "Metin rengi",
"remove_color": "Rengi kaldır",
"heading_1": "Başlık 1",
"heading_2": "Başlık 2",
"bullet_list": "Madde işaretli liste",
"ordered_list": "Numaralı liste",
"quote": "Alıntı",
"code_block": "Kod bloğu",
"align_left": "Sola hizala",
"align_center": "Ortala",
"align_right": "Sağa hizala",
"text_direction": "Metin yönü (RTL/LTR)",
"link": "Bağlantı",
"table": "Tablo",
"clear_formatting": "Biçimlendirmeyi temizle",
"undo": "Geri al",
"redo": "Yinele",
"add_row_above": "Üste satır ekle",
"add_row_below": "Alta satır ekle",
"add_column_before": "Öncesine sütun ekle",
"add_column_after": "Sonrasına sütun ekle",
"delete_row": "Satırı sil",
"delete_column": "Sütunu sil",
"toggle_header_row": "Başlık satırını aç/kapat",
"delete_table": "Tabloyu sil",
"pick_size": "Boyut seç"
},
"send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "E-posta Etiketleri",
"description": "E-postalarınızı renklerle düzenlemek için etiketler tanımlayın. Bunlar sunucuda JMAP anahtar sözcükleri olarak saklanır.",
"add_keyword": "Etiket Ekle",
"reset_defaults": "Varsayılanlara Sıfırla",
"label_field": "Görünen Ad",
"label_placeholder": "ör. İş, Kişisel, Acil",
"id_field": "Etiket Kimliği",
@@ -996,7 +1021,22 @@
"add": "Ekle",
"cancel": "İptal",
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi"
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi",
"nesting": {
"label": "İç içe etiketler",
"description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin."
},
"parent_field": "Üst etiket",
"no_parent": "Üst etiket yok",
"too_long": "Bu etiket yolu çok uzun (en fazla {max} karakter)",
"has_children_locked": "Bunun altında başka etiketler var, bu nedenle adı ve üst etiketi kilitli. Önce onları taşıyın veya kaldırın.",
"has_children_delete": "Önce bunun altındaki etiketleri kaldırın",
"visibility_field": "Kenar çubuğunda görünürlük",
"visibility": {
"show": "Göster",
"unread": "Okunmamış varsa göster",
"hide": "Gizle"
}
},
"notifications": {
"test_sound": "Bildirim sesini test et",
@@ -2001,8 +2041,7 @@
"delete": "Sil",
"mark_as_spam": "Spam bildir",
"not_spam": "Spam değil",
"color_tag": "Etiket",
"remove_color": "Etiketi kaldır",
"tag": "Etiket",
"items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et",
@@ -2336,6 +2375,9 @@
"section_address_book": "Dizin",
"select_address_book": "Bir dizin seçin...",
"section_identity": "Ad ve Kimlik",
"contact_type": "Kişi türü",
"type_person": "Kişi",
"type_organization": "Kuruluş",
"section_work": "İş ve Kuruluş",
"prefix": "Ön Ek",
"prefix_placeholder": "Dr., Bay, Bayan",
@@ -2420,7 +2462,7 @@
"cancel": "İptal",
"creating": "Oluşturuluyor...",
"updating": "Güncelleniyor...",
"name_required": "En az bir ad veya soyadı gereklidir",
"name_required": "Bir ad, soyad veya kuruluş girin",
"email_invalid": "Lütfen geçerli bir e-posta adresi girin",
"email_error_inline": "Geçersiz e-posta biçimi",
"save_failed": "Kişi kaydedilemedi",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Скинути",
"demo_tour": "Тур",
"tags": "Теги",
"show_all_tags": "Показати всі ({count})",
"show_fewer_tags": "Показати менше",
"folders": "Папки",
"mail": "Пошта",
"nav_label": "Навігація",
@@ -298,6 +300,7 @@
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Імпорт .eml або .zip",
"keyboard_shortcuts": "Комбінації клавіш (?)",
"email_source": "Джерело електронної пошти",
@@ -324,13 +327,15 @@
"view_contact": "Переглянути контакт",
"message_details": "Деталі повідомлення",
"more_reply_options": "Більше варіантів відповіді",
"set_color": "Встановити тег",
"set_tag": "Встановити тег",
"tag": "Тег",
"more_actions": "Більше дій",
"previous": "попередня",
"next": "Далі",
"move_to": "Перейти до...",
"remove_color": "Видалити тег",
"remove_tag": "Видалити тег",
"tag_filter_placeholder": "Фільтр тегів",
"tag_no_matches": "Немає відповідних тегів",
"more_count": "+ ще {count}",
"characters_count": "{count} символів",
"quick_reply_placeholder": "Напишіть швидку відповідь...",
@@ -399,17 +404,6 @@
"message_id": "ID повідомлення",
"list_info": "Інформація про список"
},
"color_tag": {
"title": "Кольоровий тег",
"red": "Червоний",
"orange": "Помаранчевий",
"yellow": "Жовтий",
"green": "Зелений",
"blue": "Синій",
"purple": "Фіолетовий",
"pink": "Рожевий",
"none": "Жодного"
},
"tooltips": {
"reply": "Відповісти (р)",
"reply_all": "Відповісти всім (а)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Відображуване ім'я",
"autocomplete_search_server": "Шукати на сервері",
"autocomplete_searching": "Пошук...",
"toolbar": {
"bold": "Жирний",
"italic": "Курсив",
"underline": "Підкреслений",
"strikethrough": "Закреслений",
"text_color": "Колір тексту",
"remove_color": "Прибрати колір",
"heading_1": "Заголовок 1",
"heading_2": "Заголовок 2",
"bullet_list": "Маркований список",
"ordered_list": "Нумерований список",
"quote": "Цитата",
"code_block": "Блок коду",
"align_left": "По лівому краю",
"align_center": "По центру",
"align_right": "По правому краю",
"text_direction": "Напрямок тексту (RTL/LTR)",
"link": "Посилання",
"table": "Таблиця",
"clear_formatting": "Очистити форматування",
"undo": "Скасувати",
"redo": "Повторити",
"add_row_above": "Вставити рядок вище",
"add_row_below": "Вставити рядок нижче",
"add_column_before": "Вставити стовпець ліворуч",
"add_column_after": "Вставити стовпець праворуч",
"delete_row": "Видалити рядок",
"delete_column": "Видалити стовпець",
"toggle_header_row": "Переключити рядок заголовка",
"delete_table": "Видалити таблицю",
"pick_size": "Вибрати розмір"
},
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "Ключові слова електронної пошти",
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
"add_keyword": "Додати ключове слово",
"reset_defaults": "Скинути до значень за замовчуванням",
"label_field": "Відображуване ім'я",
"label_placeholder": "напр. Робота, Особиста, Терміново",
"id_field": "ID ключового слова",
@@ -996,7 +1021,22 @@
"add": "додати",
"cancel": "Скасувати",
"migrating": "Оновлення ключового слова в наявних електронних листах…",
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах"
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
"nesting": {
"label": "Вкладені теги",
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
},
"parent_field": "Батьківський тег",
"no_parent": "Без батьківського тега",
"too_long": "Цей шлях тега задовгий (щонайбільше {max} символів)",
"has_children_locked": "У цей тег вкладено інші теги, тому його назву та батьківський тег заблоковано. Спочатку перемістіть або видаліть їх.",
"has_children_delete": "Спочатку видаліть теги, вкладені в цей",
"visibility_field": "Видимість на бічній панелі",
"visibility": {
"show": "Показувати",
"unread": "Показувати за непрочитаних",
"hide": "Приховувати"
}
},
"notifications": {
"test_sound": "Тестовий звук сповіщення",
@@ -2001,8 +2041,7 @@
"delete": "Видалити",
"mark_as_spam": "Повідомити про спам",
"not_spam": "Не спам",
"color_tag": "Мітка",
"remove_color": "Видалити мітку",
"tag": "Мітка",
"items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання",
@@ -2336,6 +2375,9 @@
"section_address_book": "Довідник",
"select_address_book": "Виберіть каталог...",
"section_identity": "Ім'я та ідентифікація",
"contact_type": "Тип контакту",
"type_person": "Людина",
"type_organization": "організація",
"section_work": "Робота та організація",
"prefix": "Префікс",
"prefix_placeholder": "доктор, пан, місіс",
@@ -2420,7 +2462,7 @@
"cancel": "Скасувати",
"creating": "Створення...",
"updating": "Оновлення...",
"name_required": "Потрібне принаймні ім’я або прізвище",
"name_required": "Вкажіть ім'я, прізвище або організацію",
"email_invalid": "Введіть дійсну електронну адресу",
"email_error_inline": "Недійсний формат електронної пошти",
"save_failed": "Не вдалося зберегти контакт",
+60 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "重置",
"demo_tour": "引导",
"tags": "标签",
"show_all_tags": "显示全部({count}",
"show_fewer_tags": "收起",
"folders": "文件夹",
"mail": "邮件",
"nav_label": "导航",
@@ -298,6 +300,7 @@
"print": "打印",
"view_source": "查看源码",
"export_email": "导出为 .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "导入 .eml 或 .zip",
"keyboard_shortcuts": "键盘快捷键(?)",
"email_source": "邮件源码",
@@ -324,13 +327,15 @@
"view_contact": "查看联系人",
"message_details": "邮件详情",
"more_reply_options": "更多回复选项",
"set_color": "设置颜色标签",
"set_tag": "设置颜色标签",
"tag": "标签",
"more_actions": "更多操作",
"previous": "上一封",
"next": "下一封",
"move_to": "移动到…",
"remove_color": "删除标签",
"remove_tag": "删除标签",
"tag_filter_placeholder": "筛选标签",
"tag_no_matches": "没有匹配的标签",
"more_count": "+{count} 更多",
"characters_count": "{count} 个字符",
"quick_reply_placeholder": "快速回复...",
@@ -399,17 +404,6 @@
"message_id": "消息 ID",
"list_info": "邮件列表信息"
},
"color_tag": {
"title": "颜色标签",
"red": "红色",
"orange": "橙色",
"yellow": "黄色",
"green": "绿色",
"blue": "蓝色",
"purple": "紫色",
"pink": "粉色",
"none": "无"
},
"tooltips": {
"reply": "回复 (r)",
"reply_all": "全部回复 (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "显示名称",
"autocomplete_search_server": "在服务器上搜索",
"autocomplete_searching": "搜索中...",
"toolbar": {
"bold": "加粗",
"italic": "斜体",
"underline": "下划线",
"strikethrough": "删除线",
"text_color": "文字颜色",
"remove_color": "移除颜色",
"heading_1": "标题 1",
"heading_2": "标题 2",
"bullet_list": "项目符号列表",
"ordered_list": "编号列表",
"quote": "引用",
"code_block": "代码块",
"align_left": "左对齐",
"align_center": "居中对齐",
"align_right": "右对齐",
"text_direction": "文字方向 (RTL/LTR)",
"link": "链接",
"table": "表格",
"clear_formatting": "清除格式",
"undo": "撤销",
"redo": "重做",
"add_row_above": "在上方添加行",
"add_row_below": "在下方添加行",
"add_column_before": "在前面添加列",
"add_column_after": "在后面添加列",
"delete_row": "删除行",
"delete_column": "删除列",
"toggle_header_row": "切换标题行",
"delete_table": "删除表格",
"pick_size": "选择大小"
},
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。"
},
"confirm_dialog": {
@@ -983,7 +1009,6 @@
"title": "电子邮件标签",
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
"add_keyword": "添加标签",
"reset_defaults": "重置为默认值",
"label_field": "显示名称",
"label_placeholder": "例如工作、个人、紧急",
"id_field": "标签ID",
@@ -996,7 +1021,22 @@
"add": "添加",
"cancel": "取消",
"migrating": "正在更新现有邮件的标签…",
"migration_error": "更新现有邮件的标签失败"
"migration_error": "更新现有邮件的标签失败",
"nesting": {
"label": "嵌套标签",
"description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。"
},
"parent_field": "上级标签",
"no_parent": "无上级标签",
"too_long": "此标签路径过长(最多 {max} 个字符)",
"has_children_locked": "此标签下嵌套了其他标签,因此其名称和上级标签已锁定。请先移动或删除它们。",
"has_children_delete": "请先删除嵌套在此标签下的标签",
"visibility_field": "侧边栏显示",
"visibility": {
"show": "显示",
"unread": "有未读时显示",
"hide": "隐藏"
}
},
"notifications": {
"test_sound": "测试通知声音",
@@ -2001,8 +2041,7 @@
"delete": "删除",
"mark_as_spam": "举报垃圾邮件",
"not_spam": "不是垃圾邮件",
"color_tag": "标签",
"remove_color": "删除标签",
"tag": "标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送",
@@ -2336,6 +2375,9 @@
"section_address_book": "地址簿",
"select_address_book": "选择地址簿...",
"section_identity": "姓名和身份",
"contact_type": "联系人类型",
"type_person": "个人",
"type_organization": "组织",
"section_work": "工作与组织",
"prefix": "前缀",
"prefix_placeholder": "博士、先生、女士",
@@ -2420,7 +2462,7 @@
"cancel": "取消",
"creating": "创建中...",
"updating": "更新中...",
"name_required": "至少需要名字姓氏",
"name_required": "请输入名字姓氏或组织",
"email_invalid": "请输入有效的邮箱地址",
"email_error_inline": "邮箱地址格式无效",
"save_failed": "保存联系人失败",
+1
View File
@@ -59,6 +59,7 @@ const nextConfig: NextConfig = {
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
NEXT_PUBLIC_APP_VERSION: appVersion,
NEXT_PUBLIC_BASE_PATH: basePath,
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
},
};
+11
View File
@@ -73,6 +73,7 @@
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
}
@@ -9666,6 +9667,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+1
View File
@@ -97,6 +97,7 @@
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
},
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
type Store = ReturnType<typeof useEmailStore.getState>;
function makeMailbox(overrides: Partial<Mailbox>): Mailbox {
return {
id: 'inbox',
name: 'Inbox',
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
},
isSubscribed: true,
isShared: false,
...overrides,
};
}
function makeEmail(id: string, mailboxServerId: string): Email {
return {
id, threadId: `t-${id}`, mailboxIds: { [mailboxServerId]: true }, keywords: {},
size: 100, receivedAt: new Date().toISOString(),
from: [{ name: 'X', email: 'x@example.com' }], to: [{ name: 'Y', email: 'y@example.com' }],
subject: id, preview: '', hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
};
}
// Own account (JMAP acct "jmap-A", reached via local account "local-A") and a
// delegated/shared folder owned by another JMAP account ("jmap-B").
const ownInbox = makeMailbox({ id: 'inbox-A', role: 'inbox', accountId: 'jmap-A', originalId: 'srv-inbox-A' });
const ownArchive = makeMailbox({ id: 'archive-A', role: 'archive', accountId: 'jmap-A', originalId: 'srv-archive-A' });
const sharedTeamA = makeMailbox({ id: 'jmap-B:srv-teamA', name: 'TeamA', accountId: 'jmap-B', originalId: 'srv-teamA', isShared: true });
describe('email-store moveToMailboxCrossAware', () => {
let crossSpy: ReturnType<typeof vi.fn>;
let moveSpy: ReturnType<typeof vi.fn>;
const client = {} as IJMAPClient;
beforeEach(() => {
const email = makeEmail('e1', 'srv-inbox-A');
email.accountId = 'local-A';
crossSpy = vi.fn().mockResolvedValue(undefined);
moveSpy = vi.fn().mockResolvedValue(undefined);
useEmailStore.setState({
emails: [email],
mailboxes: [ownInbox, ownArchive, sharedTeamA],
selectedMailbox: 'inbox-A',
viewingAccountId: 'local-A',
isUnifiedView: false,
accountMailboxes: {},
crossAccountMoveEmails: crossSpy as unknown as Store['crossAccountMoveEmails'],
moveToMailbox: moveSpy as unknown as Store['moveToMailbox'],
});
});
it('routes an own → shared (cross-account) move through crossAccountMoveEmails', async () => {
await useEmailStore.getState().moveToMailboxCrossAware(client, 'e1', 'jmap-B:srv-teamA');
expect(moveSpy).not.toHaveBeenCalled();
// copy into the owner's (jmap-B) TeamA via the viewer's client, using the
// destination's raw server id; source is own, so no source override.
expect(crossSpy).toHaveBeenCalledWith(
new Map([['local-A', ['e1']]]),
'local-A',
'srv-teamA',
'jmap-B',
undefined,
);
});
it('routes a same-account move through the single-account moveToMailbox', async () => {
await useEmailStore.getState().moveToMailboxCrossAware(client, 'e1', 'archive-A');
expect(crossSpy).not.toHaveBeenCalled();
expect(moveSpy).toHaveBeenCalledWith(client, 'e1', 'archive-A');
});
it('reverse: shared → own also routes cross-account (source override set)', async () => {
const email = makeEmail('e2', 'srv-teamA');
email.accountId = 'local-A';
useEmailStore.setState({ emails: [email], selectedMailbox: 'jmap-B:srv-teamA' });
await useEmailStore.getState().moveToMailboxCrossAware(client, 'e2', 'inbox-A');
expect(moveSpy).not.toHaveBeenCalled();
expect(crossSpy).toHaveBeenCalledWith(
new Map([['local-A', ['e2']]]),
'local-A',
'srv-inbox-A',
undefined, // dest (own) not shared
'jmap-B', // source shared → override to owner account
);
});
});

Some files were not shown because too many files have changed in this diff Show More