Merge branch 'bulwarkmail:main' into user-api-plugin
This commit is contained in:
@@ -39,6 +39,16 @@ SETTINGS_SYNC_ENABLED=true
|
|||||||
LOG_FORMAT=text
|
LOG_FORMAT=text
|
||||||
LOG_LEVEL=debug
|
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)
|
# Login Page Customization (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+162
-1
@@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
# 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
|
# 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 issuer's public hostname resolves to an internal IP from this server.
|
||||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
# 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
|
# Session & Security
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -132,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# so the instance id and consent choice survive upgrades.
|
# so the instance id and consent choice survive upgrades.
|
||||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
# 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
|
# 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
|
# Should match your app's main background color. Default: #ffffff
|
||||||
# PWA_BACKGROUND_COLOR=#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
|
# Logos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -234,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
|||||||
# URL for the company website link on the login page.
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
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)
|
# 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).
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# 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
|
# Internationalization
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -274,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
#
|
#
|
||||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||||
# match any supported locale. Defaults to "en".
|
# 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
|
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||||
|
|
||||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||||
|
|||||||
+75
-29
@@ -10,13 +10,13 @@
|
|||||||
|
|
||||||
# Contributing to Bulwark Webmail
|
# 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
|
- **Get support** - real-time help with development hurdles
|
||||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
- **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:
|
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**:
|
3. **Set up environment**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.dev.example .env.local
|
||||||
# Edit .env.local with your JMAP server URL
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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**:
|
4. **Start development server**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
Then open http://localhost:3000.
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
|
||||||
Before submitting a pull request, ensure your code passes all checks:
|
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.
|
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
|
### TypeScript
|
||||||
|
|
||||||
@@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
|||||||
- Avoid `any` types when possible
|
- Avoid `any` types when possible
|
||||||
- Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
|
|
||||||
### React Components
|
### React components
|
||||||
|
|
||||||
- Use functional components with hooks
|
- Use functional components with hooks
|
||||||
- Keep components focused and single-purpose
|
- Keep components focused and single-purpose
|
||||||
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
|||||||
|
|
||||||
## Internationalization (i18n)
|
## 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
|
### Rules
|
||||||
|
|
||||||
@@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
router.push(`/${params.locale}/settings`);
|
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**:
|
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
|
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
|
4. **Update translations** if you added user-facing text
|
||||||
|
|
||||||
5. **Run all checks**:
|
5. **Run all checks**:
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck && npm run lint
|
npm run typecheck && npm run lint && npx vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Submitting
|
### Submitting
|
||||||
@@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
- Screenshots for UI changes
|
- Screenshots for UI changes
|
||||||
- Reference to any related issues
|
- Reference to any related issues
|
||||||
|
|
||||||
### Commit Message Convention
|
### Commit message convention
|
||||||
|
|
||||||
Follow the conventional commits format:
|
Follow the conventional commits format:
|
||||||
|
|
||||||
@@ -177,25 +211,37 @@ fix: resolve attachment download issue
|
|||||||
docs: update README with keyboard shortcuts
|
docs: update README with keyboard shortcuts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
webmail/
|
webmail/
|
||||||
├── app/ # Next.js App Router pages
|
├── app/ # Next.js App Router
|
||||||
│ └── [locale]/ # Locale-aware routing
|
│ ├── (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
|
├── components/ # React components
|
||||||
│ ├── email/ # Email-related components
|
│ ├── email/ # Email list, viewer, composer
|
||||||
│ ├── layout/ # Layout components
|
│ ├── calendar/ contacts/ files/ filters/ templates/
|
||||||
│ ├── settings/ # Settings components
|
│ ├── layout/ # Sidebar, shell, navigation
|
||||||
│ └── ui/ # Reusable UI components
|
│ ├── settings/ # Settings panels
|
||||||
|
│ ├── plugins/ # Plugin host UI
|
||||||
|
│ └── ui/ # Reusable primitives
|
||||||
├── contexts/ # React contexts
|
├── contexts/ # React contexts
|
||||||
├── hooks/ # Custom React hooks
|
├── hooks/ # Custom React hooks
|
||||||
|
├── i18n/ # next-intl routing, locale detection, RTL direction
|
||||||
├── lib/ # Utilities and libraries
|
├── lib/ # Utilities and libraries
|
||||||
│ └── jmap/ # JMAP client implementation
|
│ ├── jmap/ # JMAP client implementation
|
||||||
├── locales/ # Translation files
|
│ ├── stalwart/ # Stalwart-specific admin/API helpers
|
||||||
│ ├── en/ # English translations
|
│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
|
||||||
│ └── fr/ # French translations
|
│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening
|
||||||
└── stores/ # Zustand state stores
|
│ └── __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
|
## Security
|
||||||
|
|||||||
+111
-107
@@ -2,145 +2,149 @@
|
|||||||
|
|
||||||
## Mail
|
## Mail
|
||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
- 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 with inline expansion and an optional conversation toggle
|
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
|
||||||
- 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
|
- 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.
|
||||||
- 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
|
- 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 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
|
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
|
||||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `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
|
- 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 and configurable send delay
|
- Scheduled send, plus a configurable delay before anything leaves the outbox
|
||||||
- Read receipts (MDN, RFC 8098)
|
- Read receipts (MDN, RFC 8098)
|
||||||
- Editable, layout-preserving quote island when replying
|
- Quoted text lands in an editable island that keeps the original layout
|
||||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
- Batch operations – multi-select, archive, delete, move, tag
|
- Multi-select for batch archive, delete, move, and tag
|
||||||
- Archive modes – direct, by year, or by month
|
- Archive directly, by year, or by month
|
||||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
||||||
- Star/unstar with configurable mark-as-read delay
|
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
- Star or unstar, with a configurable mark-as-read delay
|
||||||
- Plain-text composer mode and Reply-To support
|
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||||
- Configurable signature position (above or below quoted text) per identity
|
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||||
- 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
|
- Plain-text composer mode and Reply-To
|
||||||
- `.eml` file import via folder right-click menu
|
- 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
|
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||||
- Folder management with icon picker, subfolders, and sidebar counts
|
- Folders take an icon, nest, and show counts in the sidebar
|
||||||
- Print directly from the viewer
|
- Print from the viewer
|
||||||
- Browser history sync for back/forward navigation
|
- Browser back and forward move through mail history
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
|
|
||||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
|
||||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
|
||||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
- Recurring events edit and delete by scope: this occurrence, this and following, or all
|
||||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
|
||||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
|
||||||
- iCalendar import with preview, bulk create, and UID deduplication
|
- iCalendar import previews first, then bulk-creates, deduplicating on UID
|
||||||
- iCal / webcal subscriptions with editing and batch import
|
- iCal / webcal subscriptions, editable, with batch import
|
||||||
- Auto-generated birthday calendar from contacts
|
- A birthday calendar generated from your contacts
|
||||||
- Virtual locations (video conference URLs) as first-class event fields
|
- Virtual locations (video-conference URLs) are first-class event fields
|
||||||
- Task management with due dates, priority, and completion status
|
- Tasks with due dates, priority, and completion status
|
||||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
|
||||||
- Week numbers, event hover preview, notifications with sound picker
|
- Week numbers, hover preview, notifications with a sound picker
|
||||||
- Real-time sync via JMAP push
|
- JMAP push keeps everything in sync
|
||||||
|
|
||||||
## Contacts
|
## Contacts
|
||||||
|
|
||||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
- JMAP sync (RFC 9553 / 9610), falling back to local storage
|
||||||
- Multiple address books with drag-and-drop between books
|
- Several address books, with drag-and-drop between them
|
||||||
- Contact groups with member management
|
- Groups with member management
|
||||||
- vCard import/export (RFC 6350) with duplicate detection
|
- vCard import/export (RFC 6350) that flags duplicates
|
||||||
- Trusted senders stored in a dedicated JMAP address book
|
- Trusted senders live in their own JMAP address book
|
||||||
- Autocomplete in the composer (To / Cc / Bcc)
|
- Autocomplete on To, Cc, and Bcc
|
||||||
|
|
||||||
## Filters & Templates
|
## Filters & templates
|
||||||
|
|
||||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
- Server-side filters as 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…)
|
- 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
|
||||||
- Preserves rules authored in other clients
|
- Rules written in other clients survive the round-trip
|
||||||
- Raw Sieve editor with syntax validation
|
- Raw Sieve editor with syntax validation
|
||||||
- Vacation responder with date range scheduling
|
- A vacation responder you can schedule to a date range
|
||||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||||
|
|
||||||
## Files
|
## 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
|
- 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 and folder upload with progress tracking
|
- Streamed WebDAV PUT upload, whole folders included, with progress
|
||||||
- Dynamic upload limits based on server configuration
|
- Upload limits follow the server's own configuration
|
||||||
- Grid and list views with sorting by name, size, or date
|
- Grid or list, sorted by name, size, or date
|
||||||
- Previews for images, text, audio, and video
|
- Preview images, text, audio, and video
|
||||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
- Cut, copy, paste, duplicate; favorites; 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
|
- 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
|
- External content stays blocked until you say otherwise, and trusted senders are remembered
|
||||||
- HTML sanitization via DOMPurify
|
- HTML sanitized through DOMPurify
|
||||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
|
||||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop 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
|
- 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
|
- TOTP two-factor authentication
|
||||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
- Password and 2FA management through the Stalwart admin API
|
||||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
|
||||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
|
||||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
- Plugins are scanned for dangerous patterns and need admin approval
|
||||||
- Newsletter unsubscribe (RFC 2369)
|
- Newsletter unsubscribe (RFC 2369)
|
||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
- Split three-pane, focused list, or bottom reading pane, columns resizable
|
||||||
- Dark and light themes with intelligent email color transformation
|
- 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 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
|
- 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.
|
||||||
- Responsive desktop, tablet, and mobile layouts
|
- Layouts for desktop, tablet, and mobile
|
||||||
- Full keyboard navigation
|
- Full keyboard navigation
|
||||||
- Drag-and-drop email organization and tag assignment
|
- Drag and drop to organize mail and assign tags
|
||||||
- Interactive guided tour for new users
|
- A guided tour for first-time users
|
||||||
- Right-click context menus, toast notifications with undo
|
- Right-click menus, and toasts that offer an undo
|
||||||
- Customizable toolbar position, favicon, and login branding
|
- Toolbar position, favicon, and login branding are configurable
|
||||||
- Pinnable sidebar apps with drag-and-drop reordering
|
- Sidebar apps pin and reorder by drag
|
||||||
- Encrypted settings sync across devices
|
- Settings sync between devices, encrypted
|
||||||
- Storage quota display
|
- 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
|
## 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)
|
- 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.
|
||||||
- Account switcher with connection status and default account selection
|
- An account switcher showing connection status, and a default account
|
||||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
|
||||||
- Configurable signature position (above or below quoted text)
|
- Signature above or below the quoted text
|
||||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
|
||||||
- Shared folders across accounts
|
- 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
|
- 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.
|
||||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
- Several JMAP servers per deployment, optionally auto-picked by email domain
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- 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
|
- 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.
|
||||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
- The Stalwart admin dashboard, its policy sections collapsed into one 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
|
- 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.
|
||||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
- 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.
|
||||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
|
||||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
- An admin toggle controls 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 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
|
||||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
|
||||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
- Themes upload as ZIP bundles, and admins can enforce one
|
||||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
- 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 calendar integration
|
- Bundled plugins, including Jitsi Meet for the calendar
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
|
||||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
- 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 category-based levels
|
- Structured logging (`text` or `json`) with per-category 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
|
- 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.
|
||||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
- Docker images on GHCR, for release (`main`) and development (`dev`)
|
||||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
|
||||||
- Demo mode with fixture data – no mail server required
|
- Demo mode runs on fixture data, no mail server required
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
# Bulwark Webmail
|
# 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)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
@@ -20,12 +20,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
## Installer
|
## Installer
|
||||||
|
|
||||||
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
Since **1.6.4**, a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||||
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
|
|
||||||
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
|
|
||||||
</picture>
|
|
||||||
|
|
||||||
Point a browser at the running container and the wizard guides you through:
|
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>
|
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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
|
- **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
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **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)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick start
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -104,9 +99,9 @@ Or with Docker Compose:
|
|||||||
docker compose up -d
|
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
|
```bash
|
||||||
git clone https://github.com/bulwarkmail/webmail.git
|
git clone https://github.com/bulwarkmail/webmail.git
|
||||||
@@ -119,16 +114,20 @@ npm run build && npm start
|
|||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```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 typecheck
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npx vitest run # Unit tests
|
||||||
|
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## 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
|
```env
|
||||||
# Optional – overrides whatever the wizard writes
|
# Optional – overrides whatever the wizard writes
|
||||||
@@ -151,13 +150,28 @@ PORT=3000
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
|
OAUTH_ONLY=true # hide the username/password form entirely
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
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>
|
</details>
|
||||||
|
|
||||||
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
|
|||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary>Subpath / reverse proxy mount</summary>
|
<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 .
|
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>
|
</details>
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| ------------- | ----------------------- |
|
| -------------------- | ----------------------- |
|
||||||
| `j` / `k` | Navigate between emails |
|
| `j` `↓` / `k` `↑` | Navigate between emails |
|
||||||
| `Enter` / `o` | Open email |
|
| `Enter` / `o` | Open email |
|
||||||
| `Esc` | Close / deselect |
|
| `Esc` | Close / deselect |
|
||||||
|
| `x` | Expand / collapse thread |
|
||||||
| `c` | Compose |
|
| `c` | Compose |
|
||||||
| `r` / `R` | Reply / Reply all |
|
| `r` / `R` `a` | Reply / Reply all |
|
||||||
| `f` | Forward |
|
| `f` | Forward |
|
||||||
| `s` | Star |
|
| `s` | Star |
|
||||||
| `e` | Archive |
|
| `e` | Archive |
|
||||||
| `#` | Delete |
|
| `#` / `Del` | Delete |
|
||||||
|
| `u` / `Shift`+`I` | Mark unread / read |
|
||||||
|
| `!` | Toggle spam |
|
||||||
|
| `Ctrl`+`A` | Select all |
|
||||||
|
| `Shift`+`G` | Refresh |
|
||||||
| `/` | Search |
|
| `/` | Search |
|
||||||
| `?` | Show all shortcuts |
|
| `?` | 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 |
|
| **Language** | TypeScript |
|
||||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||||
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
||||||
| **Protocol** | Custom JMAP client (RFC 8620) |
|
| **Protocol** | Custom JMAP client (RFC 8620) |
|
||||||
|
| **Editor** | [Tiptap](https://tiptap.dev/) |
|
||||||
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
||||||
| **Icons** | [Lucide React](https://lucide.dev/) |
|
| **Icons** | [Lucide React](https://lucide.dev/) |
|
||||||
|
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
|
||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
|
|||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
|
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
|
|||||||
<TourProvider>
|
<TourProvider>
|
||||||
<ProtocolLaunchHandlerProvider>
|
<ProtocolLaunchHandlerProvider>
|
||||||
<ProInterfaceRedirect />
|
<ProInterfaceRedirect />
|
||||||
|
<ImpersonationReconciler />
|
||||||
{children}
|
{children}
|
||||||
<PluginDialogHost />
|
<PluginDialogHost />
|
||||||
<PluginConsentDialog />
|
<PluginConsentDialog />
|
||||||
|
|||||||
+138
-27
@@ -34,6 +34,7 @@ import { debug } from "@/lib/debug";
|
|||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
|
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
SidebarErrorFallback,
|
SidebarErrorFallback,
|
||||||
@@ -61,7 +62,8 @@ import { isFilePreviewable } from "@/lib/file-preview";
|
|||||||
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
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 { 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 { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
@@ -77,6 +79,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
|
|||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
@@ -282,6 +285,7 @@ export default function Home() {
|
|||||||
toggleStar,
|
toggleStar,
|
||||||
setEmailKeywordsLocal,
|
setEmailKeywordsLocal,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
|
moveToMailboxCrossAware,
|
||||||
moveThreadToMailbox,
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -783,7 +787,15 @@ export default function Home() {
|
|||||||
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEmbedded || !showComposer) return;
|
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,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
@@ -799,7 +811,7 @@ export default function Home() {
|
|||||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||||
quoteHeaderText: composerQuoteHeader?.text,
|
quoteHeaderText: composerQuoteHeader?.text,
|
||||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||||
} : undefined;
|
} : undefined);
|
||||||
|
|
||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||||
@@ -1538,6 +1550,77 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
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) => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToDelete) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
@@ -1752,7 +1835,7 @@ export default function Home() {
|
|||||||
keywords['$pinned'] = true;
|
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)
|
// account via the login it is reachable through. (#281)
|
||||||
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||||
const pinAccountId = isUnifiedView ? email.sourceAccountId : 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;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove any existing label/color tags
|
// Remove any existing tag keywords
|
||||||
const email = emails.find(e => e.id === emailId);
|
const email = emails.find(e => e.id === emailId);
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
||||||
const keywords = { ...email.keywords };
|
const keywords = { ...email.keywords };
|
||||||
|
|
||||||
if (color === null) {
|
if (tagId === null) {
|
||||||
// Remove all label/color tags
|
// Remove all tag keywords
|
||||||
Object.keys(keywords).forEach(key => {
|
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;
|
keywords[key] = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const jmapKey = `$label:${color}`;
|
// Both prefixes name the same tag when read, so taking one off has to
|
||||||
if (keywords[jmapKey]) {
|
// clear whichever spellings are actually set.
|
||||||
// Toggle off if already active
|
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||||
keywords[jmapKey] = false;
|
.filter(key => keywords[key]);
|
||||||
|
if (activeKeys.length > 0) {
|
||||||
|
activeKeys.forEach(key => {
|
||||||
|
keywords[key] = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add the tag without disturbing others
|
// 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
|
// Refresh tag counts
|
||||||
fetchTagCounts(client);
|
fetchTagCounts(client);
|
||||||
} catch (error) {
|
} 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) => {
|
const handleQuickReply = async (body: string) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !selectedEmail) return;
|
||||||
|
|
||||||
const sender = selectedEmail.from?.[0];
|
// Quick reply follows the same addressing rules as the composer: Reply-To
|
||||||
if (!sender?.email) {
|
// 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");
|
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
|
// Decide the sending identity and (for domain-catch-all) an optional
|
||||||
// header From override that matches the address the message was sent to.
|
// 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.
|
// When the setting is off, fall through to primary-identity behavior.
|
||||||
const resolved = autoSelectReplyIdentity
|
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
|
||||||
? resolveReplyFrom(identities, {
|
? findDraftIdentityId(identities, selectedEmail.from?.[0])
|
||||||
|
: null;
|
||||||
|
const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity
|
||||||
|
? null
|
||||||
|
: selfSentIdentityId
|
||||||
|
? { identityId: selfSentIdentityId }
|
||||||
|
: resolveReplyFrom(identities, {
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
bcc: selectedEmail.bcc,
|
bcc: selectedEmail.bcc,
|
||||||
})
|
});
|
||||||
: null;
|
|
||||||
const sendingIdentity = resolved
|
const sendingIdentity = resolved
|
||||||
? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity)
|
? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity)
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
@@ -2463,7 +2569,7 @@ export default function Home() {
|
|||||||
// Send reply with just the body text
|
// Send reply with just the body text
|
||||||
const result = await sendEmail(
|
const result = await sendEmail(
|
||||||
client,
|
client,
|
||||||
[sender.email],
|
recipients,
|
||||||
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||||
finalBody,
|
finalBody,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -3205,6 +3311,10 @@ export default function Home() {
|
|||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
handleForward();
|
handleForward();
|
||||||
}}
|
}}
|
||||||
|
onForwardAsAttachment={(email) => {
|
||||||
|
selectEmail(email);
|
||||||
|
handleForwardAsAttachment(email);
|
||||||
|
}}
|
||||||
onMarkAsRead={async (email, read) => {
|
onMarkAsRead={async (email, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, email.id, read);
|
await markAsRead(client, email.id, read);
|
||||||
@@ -3224,12 +3334,12 @@ export default function Home() {
|
|||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
await handleArchive(email);
|
await handleArchive(email);
|
||||||
}}
|
}}
|
||||||
onSetColorTag={(emailId, color) => {
|
onSetTag={(emailId, color) => {
|
||||||
handleSetColorTag(emailId, color);
|
handleSetTag(emailId, color);
|
||||||
}}
|
}}
|
||||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await moveToMailbox(client, emailId, mailboxId);
|
await moveToMailboxCrossAware(client, emailId, mailboxId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMarkAsSpam={async (email) => {
|
onMarkAsSpam={async (email) => {
|
||||||
@@ -3435,6 +3545,7 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onForwardAsAttachment={handleForwardAsAttachment}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
// Deleting the open message returns to the list (Gmail-style),
|
// Deleting the open message returns to the list (Gmail-style),
|
||||||
// not the next email — unless the user turned the setting off.
|
// not the next email — unless the user turned the setting off.
|
||||||
@@ -3448,7 +3559,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetTag={handleSetTag}
|
||||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||||
onUndoSpam={() => handleUndoSpam()}
|
onUndoSpam={() => handleUndoSpam()}
|
||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
@@ -3499,7 +3610,7 @@ export default function Home() {
|
|||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
onMoveToMailbox={async (mailboxId) => {
|
onMoveToMailbox={async (mailboxId) => {
|
||||||
if (client && selectedEmail) {
|
if (client && selectedEmail) {
|
||||||
await moveToMailbox(client, selectedEmail.id, mailboxId);
|
await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={isMobile ? "flex-1" : undefined}
|
className={isMobile ? "flex-1" : undefined}
|
||||||
|
|||||||
@@ -55,9 +55,23 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
||||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
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', {
|
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 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
|
|||||||
* Master-user impersonation via signed JWT. The token carries the target
|
* Master-user impersonation via signed JWT. The token carries the target
|
||||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||||
* master credentials from env, then mints the same session cookies the
|
* 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.
|
* 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
|
* 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.
|
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 303,
|
status: 303,
|
||||||
headers: { Location: '/' },
|
headers: { Location: '/?impersonated=1' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+561
-308
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@@ -558,6 +559,40 @@ body {
|
|||||||
overscroll-behavior: none;
|
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) */
|
/* Slide in from right animation (for mobile views) */
|
||||||
@keyframes slide-in-from-right {
|
@keyframes slide-in-from-right {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -102,4 +102,98 @@ describe('ContactForm', () => {
|
|||||||
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
|
|
||||||
const hasNickname = nicknames.length > 0;
|
const hasNickname = nicknames.length > 0;
|
||||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
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 hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||||
|
|||||||
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
|
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(() => {
|
const [jobTitle, setJobTitle] = useState(() => {
|
||||||
if (contact?.titles) {
|
if (contact?.titles) {
|
||||||
const t = Object.values(contact.titles).find(t => t.kind !== "role");
|
const t = Object.values(contact.titles).find(t => t.kind !== "role");
|
||||||
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
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"));
|
setError(t("name_required"));
|
||||||
return;
|
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.
|
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||||
const nameComponents = [];
|
const nameComponents = [];
|
||||||
|
if (!isOrg) {
|
||||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
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 (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.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 (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.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" }> = {};
|
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
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 =
|
const mediaValue: Record<string, ContactMedia> | null | undefined =
|
||||||
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? 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> = {
|
const data: Partial<ContactCard> = {
|
||||||
name: { components: nameComponents, isOrdered: true },
|
name: nameValue,
|
||||||
|
...(kindValue ? { kind: kindValue } : {}),
|
||||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||||
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
||||||
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
||||||
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
||||||
organizations: organization.trim()
|
organizations: orgName
|
||||||
? { o0: { name: organization.trim(), units: orgUnits } }
|
? { o0: { name: orgName, units: orgUnits } }
|
||||||
: undefined,
|
: undefined,
|
||||||
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
||||||
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : 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() || "";
|
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -661,6 +688,47 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormSection icon={User} title={t("section_identity")}>
|
<FormSection icon={User} title={t("section_identity")}>
|
||||||
|
<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>
|
||||||
|
{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 className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||||
@@ -693,6 +761,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
@@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Work & Organization */}
|
{/* 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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{/* In organization mode the org name is the card's identity, edited above. */}
|
||||||
|
{!isOrg && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
<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")} />
|
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
|
<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")} />
|
<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,7 +36,8 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
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 { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import {
|
import {
|
||||||
rewriteCidImagesForEditor,
|
rewriteCidImagesForEditor,
|
||||||
@@ -322,31 +323,17 @@ export function EmailComposer({
|
|||||||
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
|
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
|
||||||
({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" });
|
({ 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
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = (): Recipient[] => {
|
const getInitialTo = (): Recipient[] => {
|
||||||
if (!replyTo) return [];
|
if (mode !== 'reply' && mode !== 'replyAll') return [];
|
||||||
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient);
|
||||||
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 [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitialCc = (): Recipient[] => {
|
const getInitialCc = (): Recipient[] => {
|
||||||
if (!replyTo || mode !== 'replyAll') return [];
|
if (mode !== 'replyAll') return [];
|
||||||
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
|
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient);
|
||||||
return (replyTo.cc ?? [])
|
|
||||||
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
|
|
||||||
.map(toRecipient);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitialSubject = () => {
|
const getInitialSubject = () => {
|
||||||
@@ -716,6 +703,18 @@ export function EmailComposer({
|
|||||||
|
|
||||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
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, {
|
const resolved = resolveReplyFrom(identities, {
|
||||||
to: replyTo?.to,
|
to: replyTo?.to,
|
||||||
cc: replyTo?.cc,
|
cc: replyTo?.cc,
|
||||||
@@ -755,6 +754,7 @@ export function EmailComposer({
|
|||||||
replyTo?.accountId,
|
replyTo?.accountId,
|
||||||
replyTo?.bcc,
|
replyTo?.bcc,
|
||||||
replyTo?.cc,
|
replyTo?.cc,
|
||||||
|
replyTo?.from,
|
||||||
replyTo?.to,
|
replyTo?.to,
|
||||||
selectedIdentityId,
|
selectedIdentityId,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Tag,
|
Tag,
|
||||||
X,
|
|
||||||
Check,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
Send,
|
Send,
|
||||||
File,
|
File,
|
||||||
@@ -34,10 +32,12 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
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 {
|
interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -59,12 +59,13 @@ interface EmailContextMenuProps {
|
|||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onTogglePinned?: () => void;
|
onTogglePinned?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMoveToMailbox?: (mailboxId: string) => void;
|
onMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => 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({
|
export function EmailContextMenu({
|
||||||
email,
|
email,
|
||||||
position,
|
position,
|
||||||
@@ -127,12 +114,13 @@ export function EmailContextMenu({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
@@ -149,13 +137,12 @@ export function EmailContextMenu({
|
|||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const tEmailViewer = useTranslations("email_viewer");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isPinned = email.keywords?.['$pinned'] === true;
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentTagIds = getEmailTagIds(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
// 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 isScheduled = email.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
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
|
// Build mailbox tree for move-to submenu with proper hierarchy
|
||||||
const moveTargetIds = new Set(
|
const moveTargetIds = new Set(
|
||||||
mailboxes
|
mailboxes
|
||||||
@@ -277,6 +257,12 @@ export function EmailContextMenu({
|
|||||||
onClick={() => handleAction(onForward!)}
|
onClick={() => handleAction(onForward!)}
|
||||||
disabled={!onForward}
|
disabled={!onForward}
|
||||||
/>
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Paperclip}
|
||||||
|
label={tEmailViewer("forward_as_attachment")}
|
||||||
|
onClick={() => handleAction(onForwardAsAttachment!)}
|
||||||
|
disabled={!onForwardAsAttachment || !email.blobId}
|
||||||
|
/>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -370,37 +356,13 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||||
{colorOptions.map((option) => {
|
<div className="w-56 max-w-[18rem]">
|
||||||
const isActive = currentColors.includes(option.value);
|
<TagPicker
|
||||||
return (
|
selectedIds={currentTagIds}
|
||||||
<button
|
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||||
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))}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
|
|||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||||
// the spam quick-action flips to "not spam".
|
// the spam quick-action flips to "not spam".
|
||||||
@@ -76,7 +76,7 @@ export function EmailHoverActions({
|
|||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
isInJunk = false,
|
isInJunk = false,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
@@ -112,7 +112,7 @@ export function EmailHoverActions({
|
|||||||
onArchive?.();
|
onArchive?.();
|
||||||
break;
|
break;
|
||||||
case "tag":
|
case "tag":
|
||||||
onSetColorTag?.(null);
|
onSetTag?.(null);
|
||||||
break;
|
break;
|
||||||
case "spam":
|
case "spam":
|
||||||
if (isInJunk) onUndoSpam?.();
|
if (isInJunk) onUndoSpam?.();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
|||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||||
import { SearchChips } from "@/components/search/search-chips";
|
import { SearchChips } from "@/components/search/search-chips";
|
||||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||||
|
|
||||||
@@ -33,12 +34,13 @@ interface EmailListProps {
|
|||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
|
onForwardAsAttachment?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
onTogglePinned?: (email: Email) => void;
|
onTogglePinned?: (email: Email) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (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;
|
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||||
onMarkAsSpam?: (email: Email) => void;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
@@ -63,12 +65,13 @@ export function EmailList({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
@@ -130,10 +133,20 @@ export function EmailList({
|
|||||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
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 { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
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 density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -330,6 +343,7 @@ export function EmailList({
|
|||||||
}, [density, isFocusedMailLayout, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TagDisplayContext.Provider value={tagDisplay}>
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
{/* Batch Actions Toolbar */}
|
{/* Batch Actions Toolbar */}
|
||||||
<div
|
<div
|
||||||
@@ -541,7 +555,7 @@ export function EmailList({
|
|||||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetTag={onSetTag}
|
||||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -568,9 +582,9 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
{contextMenu.data && (
|
{contextMenuEmail && (
|
||||||
<EmailContextMenu
|
<EmailContextMenu
|
||||||
email={contextMenu.data}
|
email={contextMenuEmail}
|
||||||
position={contextMenu.position}
|
position={contextMenu.position}
|
||||||
isOpen={contextMenu.isOpen}
|
isOpen={contextMenu.isOpen}
|
||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
@@ -578,24 +592,25 @@ export function EmailList({
|
|||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
currentMailboxRole={effectiveMailboxRole}
|
currentMailboxRole={effectiveMailboxRole}
|
||||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||||
selectedCount={selectedEmailIds.size}
|
selectedCount={selectedEmailIds.size}
|
||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenuEmail!)}
|
||||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenuEmail!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
||||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
onDelete={() => onDelete?.(contextMenuEmail!)}
|
||||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
onArchive={() => onArchive?.(contextMenuEmail!)}
|
||||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||||
|
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchArchive={async () => {
|
onBatchArchive={async () => {
|
||||||
@@ -642,5 +657,6 @@ export function EmailList({
|
|||||||
|
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
|
</TagDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import { withBasePath } from "@/lib/browser-navigation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
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 { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { generateEmailSource } from "@/lib/email-source";
|
import { generateEmailSource } from "@/lib/email-source";
|
||||||
@@ -19,6 +24,7 @@ import {
|
|||||||
Reply,
|
Reply,
|
||||||
ReplyAll,
|
ReplyAll,
|
||||||
Forward,
|
Forward,
|
||||||
|
Paperclip,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
Star,
|
Star,
|
||||||
@@ -73,7 +79,7 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
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 { useUIStore } from "@/stores/ui-store";
|
||||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -109,11 +115,12 @@ interface EmailViewerProps {
|
|||||||
onReply?: (draftText?: string) => void;
|
onReply?: (draftText?: string) => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (emailId: string, read: boolean) => 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;
|
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
|
||||||
onQuickReply?: (body: string) => Promise<void>;
|
onQuickReply?: (body: string) => Promise<void>;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
@@ -198,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
|||||||
return 'Attachment';
|
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
|
// Helper function to format recipients with contextual display
|
||||||
const _formatRecipients = (
|
const _formatRecipients = (
|
||||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||||
@@ -621,11 +615,12 @@ export function EmailViewer({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onDownloadAttachment,
|
onDownloadAttachment,
|
||||||
onQuickReply,
|
onQuickReply,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
@@ -664,6 +659,7 @@ export function EmailViewer({
|
|||||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -706,12 +702,6 @@ export function EmailViewer({
|
|||||||
const isScheduled = email?.isScheduled === true;
|
const isScheduled = email?.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
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
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
@@ -816,8 +806,13 @@ export function EmailViewer({
|
|||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||||
const currentColors = getCurrentColors(email?.keywords);
|
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||||
const currentColor = currentColors[0] ?? null;
|
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
|
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
|
||||||
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
||||||
@@ -1015,7 +1010,7 @@ export function EmailViewer({
|
|||||||
showToolbarLabels,
|
showToolbarLabels,
|
||||||
isLoading,
|
isLoading,
|
||||||
moveTree.length,
|
moveTree.length,
|
||||||
colorOptions.length,
|
emailKeywords.length,
|
||||||
currentColor,
|
currentColor,
|
||||||
isInJunkFolder,
|
isInJunkFolder,
|
||||||
isTablet,
|
isTablet,
|
||||||
@@ -2994,64 +2989,18 @@ export function EmailViewer({
|
|||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||||
className={cn(
|
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
title={t('set_tag')}
|
||||||
currentColors.length > 0 && "bg-muted/50"
|
|
||||||
)}
|
|
||||||
title={t('set_color')}
|
|
||||||
>
|
>
|
||||||
{currentColors.length > 0 ? (
|
<Tag className="w-4 h-4" />
|
||||||
<>
|
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||||
<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>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{tagMenuOpen && (
|
{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">
|
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => {
|
<TagPicker
|
||||||
const isActive = currentColors.includes(option.value);
|
selectedIds={currentTagIds}
|
||||||
return (
|
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3243,7 +3192,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag - submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
onMouseLeave={() => setMoreMenuSub(null)}
|
onMouseLeave={() => setMoreMenuSub(null)}
|
||||||
@@ -3257,36 +3206,11 @@ export function EmailViewer({
|
|||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
{moreMenuSub === 'tag' && (
|
{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">
|
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
{colorOptions.map((option) => {
|
<TagPicker
|
||||||
const isActive = currentColors.includes(option.value);
|
selectedIds={currentTagIds}
|
||||||
return (
|
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3340,6 +3264,16 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<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 */}
|
{/* Export email */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
@@ -3420,19 +3354,21 @@ export function EmailViewer({
|
|||||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||||
</button>
|
</button>
|
||||||
{/* Tag (opens sub-view) */}
|
{/* Tag (opens sub-view) */}
|
||||||
{colorOptions.length > 0 && (
|
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setMoreMenuSub('tag')}
|
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"
|
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" />
|
<Tag className="w-5 h-5" />
|
||||||
<span className="flex-1">{t('tag')}</span>
|
<span className="flex-1">{t('tag')}</span>
|
||||||
{currentColors.length > 0 && (
|
{currentTagIds.length > 0 && (
|
||||||
<div className="flex -space-x-1 me-1">
|
<div className="flex -space-x-1 me-1">
|
||||||
{currentColors.slice(0, 3).map((c) => {
|
{sortedTagIds.slice(0, 3).map((tagId) => (
|
||||||
const opt = colorOptions.find((o) => o.value === c);
|
<span
|
||||||
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
key={tagId}
|
||||||
})}
|
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -3462,6 +3398,15 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<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
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
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"
|
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);
|
return renderMobileNodes(moveTree);
|
||||||
})()}
|
})()}
|
||||||
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
{moreMenuSub === 'tag' && (
|
||||||
<>
|
<TagPicker
|
||||||
{colorOptions.map((option) => {
|
touch
|
||||||
const isActive = currentColors.includes(option.value);
|
selectedIds={currentTagIds}
|
||||||
return (
|
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3605,24 +3527,24 @@ export function EmailViewer({
|
|||||||
)} />
|
)} />
|
||||||
</button>
|
</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 && (
|
{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">
|
<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')}
|
{t('important')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
{/* 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">
|
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-ht
|
|||||||
import { SignatureBlock } from "@/components/email/signature-block";
|
import { SignatureBlock } from "@/components/email/signature-block";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -153,6 +154,7 @@ const TEXT_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
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);
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -180,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -343,6 +345,7 @@ export function RichTextEditor({
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const tToolbar = useTranslations("email_composer.toolbar");
|
||||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||||
@@ -383,28 +386,28 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("bold")}
|
active={editor.isActive("bold")}
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
title="Bold"
|
title={tToolbar("bold")}
|
||||||
>
|
>
|
||||||
<Bold className="w-4 h-4" />
|
<Bold className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("italic")}
|
active={editor.isActive("italic")}
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
title="Italic"
|
title={tToolbar("italic")}
|
||||||
>
|
>
|
||||||
<Italic className="w-4 h-4" />
|
<Italic className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("underline")}
|
active={editor.isActive("underline")}
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
title="Underline"
|
title={tToolbar("underline")}
|
||||||
>
|
>
|
||||||
<UnderlineIcon className="w-4 h-4" />
|
<UnderlineIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("strike")}
|
active={editor.isActive("strike")}
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
title="Strikethrough"
|
title={tToolbar("strikethrough")}
|
||||||
>
|
>
|
||||||
<Strikethrough className="w-4 h-4" />
|
<Strikethrough className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -412,7 +415,7 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={!!editor.getAttributes("textStyle").color}
|
active={!!editor.getAttributes("textStyle").color}
|
||||||
onClick={() => setColorMenuOpen((v) => !v)}
|
onClick={() => setColorMenuOpen((v) => !v)}
|
||||||
title="Text color"
|
title={tToolbar("text_color")}
|
||||||
>
|
>
|
||||||
{/* The icon itself previews the active colour - no layout shift. */}
|
{/* The icon itself previews the active colour - no layout shift. */}
|
||||||
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||||
@@ -446,7 +449,7 @@ export function RichTextEditor({
|
|||||||
setColorMenuOpen(false);
|
setColorMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RemoveFormatting className="w-4 h-4" /> Remove color
|
<RemoveFormatting className="w-4 h-4" /> {tToolbar("remove_color")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -457,14 +460,14 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("heading", { level: 1 })}
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
title="Heading 1"
|
title={tToolbar("heading_1")}
|
||||||
>
|
>
|
||||||
<Heading1 className="w-4 h-4" />
|
<Heading1 className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("heading", { level: 2 })}
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
title="Heading 2"
|
title={tToolbar("heading_2")}
|
||||||
>
|
>
|
||||||
<Heading2 className="w-4 h-4" />
|
<Heading2 className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -474,28 +477,28 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("bulletList")}
|
active={editor.isActive("bulletList")}
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
title="Bullet List"
|
title={tToolbar("bullet_list")}
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<List className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("orderedList")}
|
active={editor.isActive("orderedList")}
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
title="Ordered List"
|
title={tToolbar("ordered_list")}
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-4 h-4" />
|
<ListOrdered className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("blockquote")}
|
active={editor.isActive("blockquote")}
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
title="Quote"
|
title={tToolbar("quote")}
|
||||||
>
|
>
|
||||||
<Quote className="w-4 h-4" />
|
<Quote className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("codeBlock")}
|
active={editor.isActive("codeBlock")}
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
title="Code Block"
|
title={tToolbar("code_block")}
|
||||||
>
|
>
|
||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -505,21 +508,21 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive({ textAlign: "left" })}
|
active={editor.isActive({ textAlign: "left" })}
|
||||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||||
title="Align Left"
|
title={tToolbar("align_left")}
|
||||||
>
|
>
|
||||||
<AlignLeft className="w-4 h-4" />
|
<AlignLeft className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive({ textAlign: "center" })}
|
active={editor.isActive({ textAlign: "center" })}
|
||||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||||
title="Align Center"
|
title={tToolbar("align_center")}
|
||||||
>
|
>
|
||||||
<AlignCenter className="w-4 h-4" />
|
<AlignCenter className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive({ textAlign: "right" })}
|
active={editor.isActive({ textAlign: "right" })}
|
||||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||||
title="Align Right"
|
title={tToolbar("align_right")}
|
||||||
>
|
>
|
||||||
<AlignRight className="w-4 h-4" />
|
<AlignRight className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -534,7 +537,7 @@ export function RichTextEditor({
|
|||||||
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||||
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||||
}}
|
}}
|
||||||
title="Text direction (RTL/LTR)"
|
title={tToolbar("text_direction")}
|
||||||
>
|
>
|
||||||
<ArrowLeftRight className="w-4 h-4" />
|
<ArrowLeftRight className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -545,7 +548,7 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("link")}
|
active={editor.isActive("link")}
|
||||||
onClick={addLink}
|
onClick={addLink}
|
||||||
title="Link"
|
title={tToolbar("link")}
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -554,7 +557,7 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
active={editor.isActive("table")}
|
active={editor.isActive("table")}
|
||||||
onClick={() => setTableMenuOpen((v) => !v)}
|
onClick={() => setTableMenuOpen((v) => !v)}
|
||||||
title="Table"
|
title={tToolbar("table")}
|
||||||
>
|
>
|
||||||
<TableIcon className="w-4 h-4" />
|
<TableIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</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"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
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); }}
|
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>
|
</button>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<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"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
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); }}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
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); }}
|
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>
|
</button>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<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"
|
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); }}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -637,7 +640,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||||
title="Clear Formatting"
|
title={tToolbar("clear_formatting")}
|
||||||
>
|
>
|
||||||
<RemoveFormatting className="w-4 h-4" />
|
<RemoveFormatting className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -647,14 +650,14 @@ export function RichTextEditor({
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().undo().run()}
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
disabled={!editor.can().undo()}
|
disabled={!editor.can().undo()}
|
||||||
title="Undo"
|
title={tToolbar("undo")}
|
||||||
>
|
>
|
||||||
<Undo className="w-4 h-4" />
|
<Undo className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
disabled={!editor.can().redo()}
|
disabled={!editor.can().redo()}
|
||||||
title="Redo"
|
title={tToolbar("redo")}
|
||||||
>
|
>
|
||||||
<Redo className="w-4 h-4" />
|
<Redo className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
|
|||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-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 {
|
interface ThreadEmailItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
|
|||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
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 sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
|
|||||||
{email.hasAttachment && (
|
{email.hasAttachment && (
|
||||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
{tagIds.map((id) => (
|
||||||
|
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview snippet */}
|
{/* Preview snippet */}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
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 { 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 { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAccountStore } from "@/stores/account-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 { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
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 {
|
interface ThreadListItemProps {
|
||||||
thread: ThreadGroup;
|
thread: ThreadGroup;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
|
|||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (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;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
}
|
}
|
||||||
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
|
|||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
showPreview: boolean;
|
showPreview: boolean;
|
||||||
colorTag: string | null;
|
rowTint: string | null;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetTag?: (tagId: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
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 t = useTranslations('email_viewer');
|
||||||
const tBatch = useTranslations('email_list.batch_actions');
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
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 tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||||
const tagIds = getEmailColorTags(email.keywords);
|
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
|
||||||
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 { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
email,
|
email,
|
||||||
@@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
data-unread={isUnread ? 'true' : 'false'}
|
data-unread={isUnread ? 'true' : 'false'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
resolvedColorTag ? resolvedColorTag : (
|
resolvedRowTint ? resolvedRowTint : (
|
||||||
selected
|
selected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !resolvedColorTag && "shadow-sm",
|
selected && !resolvedRowTint && "shadow-sm",
|
||||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20",
|
||||||
|
isChecked && !resolvedRowTint && "bg-accent/40",
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
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}
|
onClick={handleClick}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
@@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{sender?.name || sender?.email || 'Unknown'}
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
<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(
|
<span className={cn(
|
||||||
'min-w-0 truncate',
|
'min-w-0 truncate',
|
||||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
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" />}
|
{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!} />}
|
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}>
|
)}>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
</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">
|
<div className="flex items-center gap-1.5">
|
||||||
{isPinned && (
|
{isPinned && (
|
||||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<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!} />}
|
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||||
"mb-1 line-clamp-1 text-sm",
|
{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
|
isUnread
|
||||||
? "font-semibold text-foreground"
|
? "font-semibold text-foreground"
|
||||||
: "font-normal text-foreground/90"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{email.subject || "(no subject)"}
|
{email.subject || "(no subject)"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{!email.isScheduled && (
|
{!email.isScheduled && (
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetTag={onSetTag}
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
onUndoSpam={onUndoSpam}
|
onUndoSpam={onUndoSpam}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
);
|
);
|
||||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
// A collapsed row speaks for every message under it, so it carries their tags too.
|
||||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
|
||||||
|
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
thread.emails.some(e => e.id === selectedEmailId);
|
thread.emails.some(e => e.id === selectedEmailId);
|
||||||
@@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
colorTag={colorTag}
|
rowTint={rowTint}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
onArchive={onArchive ? () => onArchive(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}
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{...threadLongPressHandlers}
|
{...threadLongPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||||
colorTag ? colorTag : (
|
rowTint ? rowTint : (
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
isSelected && !colorTag && "shadow-sm",
|
isSelected && !rowTint && "shadow-sm",
|
||||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||||
isExpanded && "border-b border-border/50",
|
isExpanded && "border-b border-border/50",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20",
|
||||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
isChecked && !rowTint && "bg-accent/40",
|
||||||
|
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||||
|
isThreadPressed && !rowTint && "bg-muted"
|
||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
<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'
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
)}>
|
)}>
|
||||||
{displayNames.join(', ')}
|
{displayNames.join(', ')}
|
||||||
</span>
|
</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">
|
<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(
|
<span className={cn(
|
||||||
'min-w-0 truncate',
|
'min-w-0 truncate',
|
||||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
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" />}
|
{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!} />}
|
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}>
|
)}>
|
||||||
{displayNames.join(", ")}
|
{displayNames.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={TAG_GROUP_CLASS}>
|
||||||
className={cn(
|
<ThreadCountPill
|
||||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
count={emailCount}
|
||||||
hasUnread
|
hasUnread={hasUnread}
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
title={t('messages_tooltip', { count: emailCount })}
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
>
|
/>
|
||||||
<MessageSquare className="w-3 h-3" />
|
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||||
{emailCount}
|
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasPinned && (
|
{hasPinned && (
|
||||||
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<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!} />}
|
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
<span
|
<span
|
||||||
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||||
"mb-1 line-clamp-1 text-sm",
|
{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
|
hasUnread
|
||||||
? "font-semibold text-foreground"
|
? "font-semibold text-foreground"
|
||||||
: "font-normal text-foreground/90"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{latestEmail.subject || "(no subject)"}
|
{latestEmail.subject || "(no subject)"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{!latestEmail.isScheduled && (
|
{!latestEmail.isScheduled && (
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
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}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
onArchive={onArchive ? () => onArchive(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}
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -89,6 +90,7 @@ export function FilterRuleModal({
|
|||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const isEdit = !!rule;
|
const isEdit = !!rule;
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { tagName } = useKeywordFormat();
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || "");
|
const [name, setName] = useState(rule?.name || "");
|
||||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||||
@@ -477,7 +479,7 @@ export function FilterRuleModal({
|
|||||||
>
|
>
|
||||||
<option value="">{t("label_placeholder")}</option>
|
<option value="">{t("label_placeholder")}</option>
|
||||||
{emailKeywords.map((kw) => (
|
{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>
|
</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;
|
||||||
|
}
|
||||||
+170
-47
@@ -35,9 +35,19 @@ import {
|
|||||||
BellOff,
|
BellOff,
|
||||||
Mails,
|
Mails,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
|
MoreHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
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 { isEditableEventTarget } from "@/lib/keyboard";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
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 { useUIStore } from "@/stores/ui-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useVacationStore } from "@/stores/vacation-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 { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -241,6 +251,9 @@ function SidebarRowCounts({
|
|||||||
interface SidebarRowProps {
|
interface SidebarRowProps {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
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;
|
depth?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isVirtual?: boolean;
|
isVirtual?: boolean;
|
||||||
@@ -266,6 +279,7 @@ interface SidebarRowProps {
|
|||||||
function SidebarRow({
|
function SidebarRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
labelCandidates,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isVirtual = false,
|
isVirtual = false,
|
||||||
@@ -288,6 +302,7 @@ function SidebarRow({
|
|||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
|
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -353,7 +368,7 @@ function SidebarRow({
|
|||||||
</span>
|
</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 truncate">{label}</span>
|
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||||
<SidebarRowCounts
|
<SidebarRowCounts
|
||||||
unread={unread}
|
unread={unread}
|
||||||
total={total}
|
total={total}
|
||||||
@@ -542,78 +557,120 @@ function MailboxTreeItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_ICON_COLOR: Record<string, string> = {
|
function ShowAllTagsRow({
|
||||||
red: "text-red-600/75 dark:text-red-400/75",
|
hiddenCount,
|
||||||
orange: "text-orange-600/75 dark:text-orange-400/75",
|
showAll,
|
||||||
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
|
onToggle,
|
||||||
green: "text-green-600/75 dark:text-green-400/75",
|
isCollapsed,
|
||||||
blue: "text-blue-600/75 dark:text-blue-400/75",
|
}: {
|
||||||
purple: "text-purple-600/75 dark:text-purple-400/75",
|
hiddenCount: number;
|
||||||
pink: "text-pink-600/75 dark:text-pink-400/75",
|
showAll: boolean;
|
||||||
teal: "text-teal-600/75 dark:text-teal-400/75",
|
onToggle: () => void;
|
||||||
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
|
isCollapsed: boolean;
|
||||||
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
|
}) {
|
||||||
amber: "text-amber-600/75 dark:text-amber-400/75",
|
const t = useTranslations('sidebar');
|
||||||
lime: "text-lime-600/75 dark:text-lime-400/75",
|
|
||||||
gray: "text-gray-500",
|
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({
|
function TagItem({
|
||||||
kw,
|
node,
|
||||||
isSelected,
|
selectedKeyword,
|
||||||
|
expandedTags,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onTagSelect,
|
onTagSelect,
|
||||||
totalCount,
|
onToggleExpand,
|
||||||
unreadCount,
|
tagCounts,
|
||||||
colorful,
|
colorful,
|
||||||
}: {
|
}: {
|
||||||
kw: KeywordDefinition;
|
node: KeywordNode;
|
||||||
isSelected: boolean;
|
selectedKeyword: string | null;
|
||||||
|
expandedTags: Set<string>;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onTagSelect?: (keywordId: string | null) => void;
|
onTagSelect?: (keywordId: string | null) => void;
|
||||||
totalCount: number;
|
onToggleExpand: (keywordId: string) => void;
|
||||||
unreadCount: number;
|
tagCounts: Record<string, { total: number; unread: number }>;
|
||||||
colorful: boolean;
|
colorful: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('notifications');
|
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 { isDragging: globalDragging } = useDragDropContext();
|
||||||
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||||
tagId: kw.id,
|
tagId: node.id,
|
||||||
onSuccess: (count, _tagLabel) => {
|
onSuccess: (count) => {
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
toast.success(t('email_tagged'), kw.label);
|
toast.success(t('email_tagged'), fullLabel);
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('emails_tagged', { count }), kw.label);
|
toast.success(t('emails_tagged', { count }), fullLabel);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t('tag_failed'), kw.label);
|
toast.error(t('tag_failed'), fullLabel);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagIcon = colorful ? (
|
const tagIcon = colorful ? (
|
||||||
<Tag
|
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
||||||
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
|
|
||||||
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 (
|
return (
|
||||||
|
<>
|
||||||
<SidebarRow
|
<SidebarRow
|
||||||
icon={tagIcon}
|
icon={tagIcon}
|
||||||
label={kw.label}
|
label={label}
|
||||||
depth={0}
|
labelCandidates={labelCandidates}
|
||||||
|
depth={node.depth}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
unread={unreadCount}
|
unread={tagCounts[node.id]?.unread ?? 0}
|
||||||
total={totalCount}
|
total={tagCounts[node.id]?.total ?? 0}
|
||||||
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
||||||
|
hasChildren={hasChildren}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onExpandToggle={() => onToggleExpand(node.id)}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||||
isValidDropTarget={isValidDropTarget}
|
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 { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
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(() => {
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
@@ -779,6 +838,7 @@ export function Sidebar({
|
|||||||
return new Set();
|
return new Set();
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
// 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
|
// 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
|
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === '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 ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
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
|
// Multi-account mode (Pro shell): render every connected account as its
|
||||||
// own collapsible group. The active account's tree comes from the
|
// own collapsible group. The active account's tree comes from the
|
||||||
// `mailboxes` prop (which is the live email-store value); other accounts
|
// `mailboxes` prop (which is the live email-store value); other accounts
|
||||||
@@ -1265,18 +1379,27 @@ export function Sidebar({
|
|||||||
/>
|
/>
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{emailKeywords.map((kw) => (
|
{visibleTagTree.map((node) => (
|
||||||
<TagItem
|
<TagItem
|
||||||
key={kw.id}
|
key={node.id}
|
||||||
kw={kw}
|
node={node}
|
||||||
isSelected={selectedKeyword === kw.id}
|
selectedKeyword={selectedKeyword}
|
||||||
|
expandedTags={expandedTags}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onTagSelect={onTagSelect}
|
onTagSelect={onTagSelect}
|
||||||
totalCount={tagCounts[kw.id]?.total ?? 0}
|
onToggleExpand={handleToggleTagExpand}
|
||||||
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
tagCounts={tagCounts}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{(hiddenTagCount > 0 || showAllTags) && (
|
||||||
|
<ShowAllTagsRow
|
||||||
|
hiddenCount={hiddenTagCount}
|
||||||
|
showAll={showAllTags}
|
||||||
|
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/st
|
|||||||
import type { Email } from "@/lib/jmap/types";
|
import type { Email } from "@/lib/jmap/types";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
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 {
|
interface ProEmailTabBodyProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -56,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||||
|
|
||||||
@@ -136,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
});
|
});
|
||||||
}, [email, openComposeTab, t]);
|
}, [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 () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!client || !email) return;
|
if (!client || !email) return;
|
||||||
try {
|
try {
|
||||||
@@ -191,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
}
|
}
|
||||||
}, [client, markAsRead]);
|
}, [client, markAsRead]);
|
||||||
|
|
||||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
|
||||||
if (!email || email.id !== emailId) return;
|
if (!email || email.id !== emailId) return;
|
||||||
// Drop existing color keywords, optionally add the new one. Matches the
|
// Toggle one tag, or clear them all. Matches the mail page's local
|
||||||
// mail page's local optimistic update.
|
// optimistic update, down to reaching tags this client cannot name.
|
||||||
const keywords = { ...(email.keywords ?? {}) };
|
const keywords = { ...(email.keywords ?? {}) };
|
||||||
for (const kw of settingsKeywords) {
|
if (tagId === null) {
|
||||||
delete keywords[`$label:${kw.id}`];
|
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;
|
||||||
}
|
}
|
||||||
if (color) {
|
|
||||||
const def = settingsKeywords.find((k) => k.color === color);
|
|
||||||
if (def) keywords[`$label:${def.id}`] = true;
|
|
||||||
}
|
}
|
||||||
setEmailKeywordsLocal(emailId, keywords);
|
setEmailKeywordsLocal(emailId, keywords);
|
||||||
setEmail({ ...email, keywords });
|
setEmail({ ...email, keywords });
|
||||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
}, [email, setEmailKeywordsLocal]);
|
||||||
|
|
||||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||||
if (!client || !email) return;
|
if (!client || !email) return;
|
||||||
@@ -283,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onForwardAsAttachment={handleForwardAsAttachment}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onMarkAsRead={handleMarkAsRead}
|
onMarkAsRead={handleMarkAsRead}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetTag={handleSetTag}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { KeywordSettings } from '../keyword-settings';
|
import { KeywordSettings } from '../keyword-settings';
|
||||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||||
|
|
||||||
// Mock SettingsSection to just render children
|
// Mock SettingsSection to just render children, keeping the real controls
|
||||||
vi.mock('../settings-section', () => ({
|
vi.mock('../settings-section', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../settings-section')>()),
|
||||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('KeywordSettings', () => {
|
describe('KeywordSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all default keywords', () => {
|
it('renders all default keywords', () => {
|
||||||
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
|
|||||||
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
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', () => {
|
it('shows add form when add button clicked', () => {
|
||||||
render(<KeywordSettings />);
|
render(<KeywordSettings />);
|
||||||
fireEvent.click(screen.getByText('add_keyword'));
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
|
|||||||
expect(kw?.label).toBe('Crimson');
|
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', () => {
|
it('normalizes label to id correctly', () => {
|
||||||
render(<KeywordSettings />);
|
render(<KeywordSettings />);
|
||||||
fireEvent.click(screen.getByText('add_keyword'));
|
fireEvent.click(screen.getByText('add_keyword'));
|
||||||
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
|
|||||||
expect(added.id).toBe('my-custom-tag');
|
expect(added.id).toBe('my-custom-tag');
|
||||||
expect(added.label).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,35 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
|
||||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
function KeywordColorPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -19,8 +39,10 @@ function KeywordColorPicker({
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="space-y-1.5">
|
||||||
{PALETTE_KEYS.map((colorKey) => (
|
{KEYWORD_PALETTE_ROWS.map((row, index) => (
|
||||||
|
<div key={index} className="flex flex-wrap gap-1.5">
|
||||||
|
{row.map((colorKey) => (
|
||||||
<button
|
<button
|
||||||
key={colorKey}
|
key={colorKey}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -34,13 +56,18 @@ function KeywordColorPicker({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeywordRow({
|
function KeywordRow({
|
||||||
keyword,
|
keyword,
|
||||||
|
keywords,
|
||||||
|
nestedTags,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onVisibilityChange,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
@@ -49,8 +76,11 @@ function KeywordRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
}: {
|
}: {
|
||||||
keyword: KeywordDefinition;
|
keyword: KeywordDefinition;
|
||||||
|
keywords: KeywordDefinition[];
|
||||||
|
nestedTags: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onVisibilityChange: (visibility: KeywordVisibility) => void;
|
||||||
onDragStart: () => void;
|
onDragStart: () => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDrop: () => void;
|
onDrop: () => void;
|
||||||
@@ -59,7 +89,16 @@ function KeywordRow({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -75,9 +114,23 @@ function KeywordRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
<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")} />
|
<div className="flex min-w-0 flex-1">
|
||||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
|
||||||
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
|
</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">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -90,8 +143,9 @@ function KeywordRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
disabled={hasChildren}
|
||||||
title={t("delete")}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -102,37 +156,74 @@ function KeywordRow({
|
|||||||
|
|
||||||
function KeywordEditForm({
|
function KeywordEditForm({
|
||||||
initial,
|
initial,
|
||||||
|
keywords,
|
||||||
existingIds,
|
existingIds,
|
||||||
|
nestedTags,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: KeywordDefinition;
|
initial?: KeywordDefinition;
|
||||||
|
keywords: KeywordDefinition[];
|
||||||
existingIds: string[];
|
existingIds: string[];
|
||||||
|
nestedTags: boolean;
|
||||||
onSave: (keyword: KeywordDefinition) => void;
|
onSave: (keyword: KeywordDefinition) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const [label, setLabel] = useState(initial?.label || "");
|
const [label, setLabel] = useState(initial?.label || "");
|
||||||
const [color, setColor] = useState(initial?.color || "blue");
|
const [color, setColor] = useState(initial?.color || "blue");
|
||||||
|
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
|
||||||
const isEditing = !!initial;
|
const isEditing = !!initial;
|
||||||
|
|
||||||
const normalizedId = label
|
// Renaming or re-parenting a tag rewrites the keyword on every message below
|
||||||
.trim()
|
// it, and this client only knows about the tags in its own settings - the
|
||||||
.toLowerCase()
|
// server may hold nested keywords created elsewhere. Freeze the identity of a
|
||||||
.replace(/[^a-z0-9_-]/g, "-")
|
// tag that has children and allow the color to change.
|
||||||
.replace(/-+/g, "-")
|
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
|
|
||||||
|
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
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 = () => {
|
const handleSave = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
if (isLocked && initial) {
|
||||||
|
onSave({ ...initial, color });
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSave({ id: normalizedId, label: label.trim(), color });
|
onSave({ id: normalizedId, label: label.trim(), color });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
<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>
|
<div>
|
||||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
{t("label_field")}
|
{t("label_field")}
|
||||||
@@ -141,15 +232,29 @@ function KeywordEditForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
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")}
|
placeholder={t("label_placeholder")}
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
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 && (
|
{isDuplicate && (
|
||||||
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||||
@@ -182,7 +287,7 @@ function KeywordEditForm({
|
|||||||
|
|
||||||
export function KeywordSettings() {
|
export function KeywordSettings() {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { fetchTagCounts } = useEmailStore();
|
const { fetchTagCounts } = useEmailStore();
|
||||||
@@ -260,12 +365,19 @@ export function KeywordSettings() {
|
|||||||
removeKeyword(id);
|
removeKeyword(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetDefaults = () => {
|
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
||||||
reorderKeywords(DEFAULT_KEYWORDS);
|
updateKeyword(id, { visibility });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t("title")} description={t("description")}>
|
<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">
|
<div className="space-y-2">
|
||||||
{isMigrating && (
|
{isMigrating && (
|
||||||
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
<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
|
<KeywordEditForm
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
initial={keyword}
|
initial={keyword}
|
||||||
|
keywords={emailKeywords}
|
||||||
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onSave={handleEdit}
|
onSave={handleEdit}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
/>
|
/>
|
||||||
@@ -286,11 +400,14 @@ export function KeywordSettings() {
|
|||||||
<KeywordRow
|
<KeywordRow
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
|
keywords={emailKeywords}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingId(keyword.id);
|
setEditingId(keyword.id);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(keyword.id)}
|
onDelete={() => handleDelete(keyword.id)}
|
||||||
|
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
||||||
onDragStart={() => handleDragStart(index)}
|
onDragStart={() => handleDragStart(index)}
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
onDrop={() => handleDrop(index)}
|
onDrop={() => handleDrop(index)}
|
||||||
@@ -303,7 +420,9 @@ export function KeywordSettings() {
|
|||||||
|
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
|
keywords={emailKeywords}
|
||||||
existingIds={existingIds}
|
existingIds={existingIds}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
/>
|
/>
|
||||||
@@ -320,14 +439,6 @@ export function KeywordSettings() {
|
|||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t("add_keyword")}
|
{t("add_keyword")}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export function LayoutSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
{!isSettingHidden('enableUnifiedMailbox') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('unified_mailbox.label')}
|
label={t('unified_mailbox.label')}
|
||||||
description={t('unified_mailbox.description')}
|
description={t('unified_mailbox.description')}
|
||||||
|
|||||||
@@ -111,15 +111,24 @@ interface SelectProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: { value: string; label: string }[];
|
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 (
|
return (
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
dir="auto"
|
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) => (
|
{options.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
|
|||||||
@@ -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 */
|
/** Italy – Green, White, Red vertical */
|
||||||
export function FlagIT(props: FlagProps) {
|
export function FlagIT(props: FlagProps) {
|
||||||
return (
|
return (
|
||||||
@@ -309,6 +322,7 @@ export function FlagSK(props: FlagProps) {
|
|||||||
|
|
||||||
/** Map locale codes to flag components */
|
/** Map locale codes to flag components */
|
||||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||||
|
ca: FlagCAT,
|
||||||
cs: FlagCS,
|
cs: FlagCS,
|
||||||
sk: FlagSK,
|
sk: FlagSK,
|
||||||
da: FlagDK,
|
da: FlagDK,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { flagComponents } from './flag-icons';
|
|||||||
const languages = [
|
const languages = [
|
||||||
{ value: 'auto', label: 'Auto' },
|
{ value: 'auto', label: 'Auto' },
|
||||||
{ value: 'ar', label: 'العربية' },
|
{ value: 'ar', label: 'العربية' },
|
||||||
|
{ value: 'ca', label: 'Català' },
|
||||||
{ value: 'cs', label: 'Česky' },
|
{ value: 'cs', label: 'Česky' },
|
||||||
{ value: 'sk', label: 'Slovenčina' },
|
{ value: 'sk', label: 'Slovenčina' },
|
||||||
{ value: 'da', label: 'Dansk' },
|
{ 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -303,9 +303,8 @@ export const KEYBOARD_SHORTCUTS = {
|
|||||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||||
],
|
],
|
||||||
composer: [
|
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 + Enter", description: "shortcuts.composer.send" },
|
||||||
|
{ key: "Ctrl/Cmd + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||||
|
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
case 'ar':
|
case 'ar':
|
||||||
messages = (await import('../locales/ar/common.json')).default;
|
messages = (await import('../locales/ar/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'ca':
|
||||||
|
messages = (await import('../locales/ca/common.json')).default;
|
||||||
|
break;
|
||||||
case 'cs':
|
case 'cs':
|
||||||
messages = (await import('../locales/cs/common.json')).default;
|
messages = (await import('../locales/cs/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
|||||||
| 'always'
|
| 'always'
|
||||||
| 'as-needed';
|
| '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
|
// 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
|
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||||
|
|||||||
@@ -13,107 +13,154 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Moving mail across the own-account / shared-folder boundary, in both
|
* Moving mail across the own-account / shared-folder boundary, in both
|
||||||
* directions, and between two shared folders. The move is driven from the list
|
* directions, and between two shared folders (same owner and across owners).
|
||||||
* context menu's "Move to" submenu; the authoritative check is the server-side
|
* The move is driven from the list context menu's "Move to" submenu; the
|
||||||
* mailbox the message ends up in, with the reliably-updating (own-account)
|
* authoritative check is the server-side mailbox the message ends up in.
|
||||||
* counters checked in the UI too.
|
*
|
||||||
|
* 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()}`;
|
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||||
|
type FolderSel = Parameters<typeof folderMailboxId>[1];
|
||||||
|
|
||||||
test.describe('Shared-folder moves', () => {
|
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 jc: JmapClient; // grantee
|
||||||
let teamA: string;
|
let teamA: string;
|
||||||
let teamB: string;
|
let teamB: string;
|
||||||
|
let teamC: string; // owned by bob
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
ja = await JmapClient.connect(alice.email, alice.password);
|
ja = await JmapClient.connect(alice.email, alice.password);
|
||||||
|
jb = await JmapClient.connect(bob.email, bob.password);
|
||||||
jc = await JmapClient.connect(carol.email, carol.password);
|
jc = await JmapClient.connect(carol.email, carol.password);
|
||||||
await ja.reset();
|
await ja.reset();
|
||||||
|
await jb.reset();
|
||||||
await jc.reset();
|
await jc.reset();
|
||||||
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
||||||
teamB = await ja.createSharedFolder('TeamB', 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> {
|
// Seed a message into a mailbox and mark it read, so a lost $seen after the
|
||||||
const acct = owner === ja ? alice : carol;
|
// 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' });
|
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||||
const m = await owner.waitForEmail(subject);
|
const m = await owner.waitForEmail(subject);
|
||||||
await owner.moveEmail(m.id, mailboxId);
|
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');
|
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);
|
const inB = await ja.findEmailBySubject(s, teamB);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inB, 'message in TeamB').toBeTruthy();
|
||||||
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();
|
|
||||||
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
|
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');
|
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);
|
const inA = await ja.findEmailBySubject(s, teamA);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inA, 'message in TeamA').toBeTruthy();
|
||||||
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();
|
|
||||||
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
|
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
|
// Cross-account cases: delivery + read state must hold (our fix); removing the
|
||||||
// shared folder as a destination for an own-account message, but clicking it
|
// source is pinned test.fail below (Stalwart #1150).
|
||||||
// does NOT relocate the message across the account boundary — it stays put.
|
test('cross-owner shared -> shared: delivers and keeps read state', async ({ page }) => {
|
||||||
// Same in reverse (shared -> own). If cross-account moves get implemented,
|
const s = subj('mv-a2c');
|
||||||
// these will start passing; flip them back to plain tests then.
|
await seedRead(teamA, s);
|
||||||
test.fail('own account -> shared folder', async ({ page }) => {
|
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');
|
const s = subj('mv-own2sh');
|
||||||
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
|
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);
|
const inTeam = await ja.findEmailBySubject(s, teamA);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inTeam, 'message in shared TeamA').toBeTruthy();
|
||||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
expect(seenOf(inTeam), 'read state kept').toBe(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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
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);
|
const inOwn = await jc.findEmailBySubject(s);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inOwn, 'message in own account').toBeTruthy();
|
||||||
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
|
expect(seenOf(inOwn), 'read state kept').toBe(true);
|
||||||
await openFolder(page, { name: 'TeamA', shared: true });
|
});
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
await moveEmailTo(page, s, dest);
|
// Pinned failing: Stalwart's onSuccessDestroyOriginal leaves the original in
|
||||||
await page.waitForTimeout(2000);
|
// 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.
|
test('cross-owner shared -> shared', async ({ page }) => {
|
||||||
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
describe('POST /upload', () => {
|
||||||
it('should return a fake blob response', async () => {
|
it('should return a fake blob response', async () => {
|
||||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
sortThreadGroups,
|
sortThreadGroups,
|
||||||
getThreadParticipants,
|
getThreadParticipants,
|
||||||
mergeThreadEmails,
|
mergeThreadEmails,
|
||||||
getEmailColorTag,
|
getEmailTagId,
|
||||||
getThreadColorTag,
|
getEmailTagIds,
|
||||||
|
getThreadTagId,
|
||||||
|
getThreadTagIds,
|
||||||
} from '../thread-utils';
|
} from '../thread-utils';
|
||||||
import type { Email, ThreadGroup } from '../jmap/types';
|
import type { Email, ThreadGroup } from '../jmap/types';
|
||||||
|
|
||||||
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getEmailColorTag', () => {
|
describe('getEmailTagIds', () => {
|
||||||
it('returns label from $label: keyword', () => {
|
it('gathers every tag set on the message', () => {
|
||||||
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
|
||||||
|
.toEqual(['red', 'work']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns label from legacy $color: keyword', () => {
|
it('reads the legacy prefix alongside the current one', () => {
|
||||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no color keyword', () => {
|
it('reports a tag written under both prefixes once', () => {
|
||||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for undefined keywords', () => {
|
|
||||||
expect(getEmailColorTag(undefined)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores keywords set to false', () => {
|
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('is empty for an untagged message or none at all', () => {
|
||||||
|
expect(getEmailTagIds({ $seen: true })).toEqual([]);
|
||||||
|
expect(getEmailTagIds(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('prefers $label: over $color: when both exist', () => {
|
||||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles custom keyword ids', () => {
|
it('handles custom keyword ids', () => {
|
||||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for empty keywords object', () => {
|
it('returns null for empty keywords object', () => {
|
||||||
expect(getEmailColorTag({})).toBeNull();
|
expect(getEmailTagId({})).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getThreadColorTag', () => {
|
describe('getThreadTagId', () => {
|
||||||
it('returns first color found across thread emails', () => {
|
it('returns first color found across thread emails', () => {
|
||||||
const emails = [
|
const emails = [
|
||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$label:blue': 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', () => {
|
it('returns null when no emails have color tags', () => {
|
||||||
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
|
|||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||||
];
|
];
|
||||||
expect(getThreadColorTag(emails)).toBeNull();
|
expect(getThreadTagId(emails)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns first tag from earliest tagged email', () => {
|
it('returns first tag from earliest tagged email', () => {
|
||||||
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
|
|||||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$label:blue': 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', () => {
|
it('returns legacy tag from thread emails', () => {
|
||||||
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
|
|||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$color:green': 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', () => {
|
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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -445,6 +445,48 @@ describe("generateVCard", () => {
|
|||||||
const vcf = generateVCard([contact]);
|
const vcf = generateVCard([contact]);
|
||||||
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
|
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", () => {
|
describe("round-trip: parse → generate → parse", () => {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
getCapabilities(): Record<string, unknown> {
|
getCapabilities(): Record<string, unknown> {
|
||||||
return {
|
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:mail': {},
|
||||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
|
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
|
||||||
'urn:ietf:params:jmap:vacationresponse': {},
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
@@ -71,6 +71,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getMaxSizeUpload(): number { return 50_000_000; }
|
getMaxSizeUpload(): number { return 50_000_000; }
|
||||||
getMaxCallsInRequest(): number { return 16; }
|
getMaxCallsInRequest(): number { return 16; }
|
||||||
getMaxObjectsInGet(): number { return 500; }
|
getMaxObjectsInGet(): number { return 500; }
|
||||||
|
getMaxObjectsInSet(): number { return 500; }
|
||||||
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||||
hasDelayedSend(): boolean { return true; }
|
hasDelayedSend(): boolean { return true; }
|
||||||
getEventSourceUrl(): string | null { return null; }
|
getEventSourceUrl(): string | null { return null; }
|
||||||
@@ -1046,7 +1047,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
const node: FileNode = {
|
const node: FileNode = {
|
||||||
id: generateDemoId('file'),
|
id: generateDemoId('file'),
|
||||||
parentId, name, type: 'd', blobId: null, size: 0,
|
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);
|
this.data.fileNodes.push(node);
|
||||||
return node;
|
return node;
|
||||||
@@ -1056,7 +1057,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
const node: FileNode = {
|
const node: FileNode = {
|
||||||
id: generateDemoId('file'),
|
id: generateDemoId('file'),
|
||||||
parentId, name, type, blobId, size,
|
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);
|
this.data.fileNodes.push(node);
|
||||||
return node;
|
return node;
|
||||||
@@ -1064,7 +1065,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||||||
const node = this.data.fileNodes.find(n => n.id === id);
|
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> }> {
|
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)) {
|
for (const [id, patch] of Object.entries(updates)) {
|
||||||
const node = this.data.fileNodes.find(n => n.id === id);
|
const node = this.data.fileNodes.find(n => n.id === id);
|
||||||
if (node) {
|
if (node) {
|
||||||
Object.assign(node, patch, { updated: new Date().toISOString() });
|
Object.assign(node, patch, { modified: new Date().toISOString() });
|
||||||
updated.push(id);
|
updated.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1094,6 +1095,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
|
|
||||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
|
async copyEmailAcrossAccounts(): Promise<string> { return generateDemoId('email'); }
|
||||||
async submitEmail(): Promise<void> { /* no-op */ }
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
async submitRawEmail(blob: Blob,
|
async submitRawEmail(blob: Blob,
|
||||||
identityId: string,
|
identityId: string,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: null,
|
blobId: null,
|
||||||
size: 0,
|
size: 0,
|
||||||
created: demoDate(-30),
|
created: demoDate(-30),
|
||||||
updated: demoDate(-2),
|
modified: demoDate(-2),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-file-photos',
|
id: 'demo-file-photos',
|
||||||
@@ -22,7 +22,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: null,
|
blobId: null,
|
||||||
size: 0,
|
size: 0,
|
||||||
created: demoDate(-30),
|
created: demoDate(-30),
|
||||||
updated: demoDate(-5),
|
modified: demoDate(-5),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Documents contents
|
// Documents contents
|
||||||
@@ -34,7 +34,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: 'demo-blob-file-1',
|
blobId: 'demo-blob-file-1',
|
||||||
size: 2150,
|
size: 2150,
|
||||||
created: demoDate(-7),
|
created: demoDate(-7),
|
||||||
updated: demoDate(-2),
|
modified: demoDate(-2),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-file-quarterly-report',
|
id: 'demo-file-quarterly-report',
|
||||||
@@ -44,7 +44,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: 'demo-blob-file-2',
|
blobId: 'demo-blob-file-2',
|
||||||
size: 148480,
|
size: 148480,
|
||||||
created: demoDate(-14),
|
created: demoDate(-14),
|
||||||
updated: demoDate(-14),
|
modified: demoDate(-14),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-file-todo',
|
id: 'demo-file-todo',
|
||||||
@@ -54,7 +54,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: 'demo-blob-file-3',
|
blobId: 'demo-blob-file-3',
|
||||||
size: 410,
|
size: 410,
|
||||||
created: demoDate(-3),
|
created: demoDate(-3),
|
||||||
updated: demoDate(-1),
|
modified: demoDate(-1),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Photos contents
|
// Photos contents
|
||||||
@@ -66,7 +66,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: 'demo-blob-file-4',
|
blobId: 'demo-blob-file-4',
|
||||||
size: 1258291,
|
size: 1258291,
|
||||||
created: demoDate(-10),
|
created: demoDate(-10),
|
||||||
updated: demoDate(-10),
|
modified: demoDate(-10),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-file-team-photo',
|
id: 'demo-file-team-photo',
|
||||||
@@ -76,7 +76,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: 'demo-blob-file-5',
|
blobId: 'demo-blob-file-5',
|
||||||
size: 911360,
|
size: 911360,
|
||||||
created: demoDate(-21),
|
created: demoDate(-21),
|
||||||
updated: demoDate(-21),
|
modified: demoDate(-21),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Root-level file
|
// Root-level file
|
||||||
@@ -88,7 +88,7 @@ export function createDemoFileNodes(): FileNode[] {
|
|||||||
blobId: 'demo-blob-file-6',
|
blobId: 'demo-blob-file-6',
|
||||||
size: 68608,
|
size: 68608,
|
||||||
created: demoDate(-5),
|
created: demoDate(-5),
|
||||||
updated: demoDate(-1),
|
modified: demoDate(-1),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export interface IJMAPClient {
|
|||||||
getMaxSizeUpload(): number;
|
getMaxSizeUpload(): number;
|
||||||
getMaxCallsInRequest(): number;
|
getMaxCallsInRequest(): number;
|
||||||
getMaxObjectsInGet(): number;
|
getMaxObjectsInGet(): number;
|
||||||
|
getMaxObjectsInSet(): number;
|
||||||
getMaxDelayedSend(accountId?: string): number;
|
getMaxDelayedSend(accountId?: string): number;
|
||||||
hasDelayedSend(accountId?: string): boolean;
|
hasDelayedSend(accountId?: string): boolean;
|
||||||
getEventSourceUrl(): string | null;
|
getEventSourceUrl(): string | null;
|
||||||
@@ -345,4 +346,12 @@ export interface IJMAPClient {
|
|||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-78
@@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
|
|||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
|
import { batched, itemsPerRequest } from "./request-limits";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||||
|
|
||||||
@@ -606,18 +607,22 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emails: Email[] = [];
|
||||||
|
|
||||||
|
for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
ids: emailsId,
|
ids: batchIds,
|
||||||
properties: [...EMAIL_LIST_PROPERTIES],
|
properties: [...EMAIL_LIST_PROPERTIES],
|
||||||
}, "0"],
|
}, "0"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getResponse = response.methodResponses?.[0]?.[1];
|
const getResponse = response.methodResponses?.[0]?.[1];
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
|
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
|
||||||
const emails = (getResponse.list || []) as Email[];
|
emails.push(...((getResponse.list || []) as Email[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emails.sort((a: Email, b: Email) =>
|
emails.sort((a: Email, b: Email) =>
|
||||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
@@ -628,9 +633,6 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return emails;
|
return emails;
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get specific emails:', error);
|
console.error('Failed to get specific emails:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -1266,10 +1268,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||||
if (tagIds.length === 0) return {};
|
if (tagIds.length === 0) return {};
|
||||||
|
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 {
|
try {
|
||||||
const methodCalls: JMAPMethodCall[] = [];
|
const methodCalls: JMAPMethodCall[] = [];
|
||||||
for (let i = 0; i < tagIds.length; i++) {
|
for (let i = 0; i < batch.length; i++) {
|
||||||
const keyword = `$label:${tagIds[i]}`;
|
const keyword = `$label:${batch[i]}`;
|
||||||
// Total count for this tag
|
// Total count for this tag
|
||||||
methodCalls.push(["Email/query", {
|
methodCalls.push(["Email/query", {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
@@ -1293,29 +1301,29 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
const result: Record<string, { total: number; unread: number }> = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < tagIds.length; i++) {
|
for (let i = 0; i < batch.length; i++) {
|
||||||
const totalResp = response.methodResponses?.[i * 2]?.[1];
|
const totalResp = response.methodResponses?.[i * 2]?.[1];
|
||||||
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
|
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
|
||||||
result[tagIds[i]] = {
|
result[batch[i]] = {
|
||||||
total: totalResp?.total ?? 0,
|
total: totalResp?.total ?? 0,
|
||||||
unread: unreadResp?.total ?? 0,
|
unread: unreadResp?.total ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get tag counts:', error);
|
console.error('Failed to get tag counts:', error);
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-tab unread counts for message-list category tabs. One Email/query
|
* Per-tab unread counts for message-list category tabs. One Email/query
|
||||||
* (limit 0, calculateTotal) per tab, batched in a single request. Each
|
* (limit 0, calculateTotal) per tab, batched into as few requests as the
|
||||||
* entry's `filter` is the tab's resolved FilterCondition/FilterOperator
|
* server's method-call ceiling allows. Each entry's `filter` is the tab's
|
||||||
* (null = no extra condition, i.e. all unread in the mailbox).
|
* resolved FilterCondition/FilterOperator (null = no extra condition, i.e.
|
||||||
|
* all unread in the mailbox).
|
||||||
*/
|
*/
|
||||||
async getCategoryUnreadCounts(
|
async getCategoryUnreadCounts(
|
||||||
mailboxId: string,
|
mailboxId: string,
|
||||||
@@ -1324,8 +1332,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
): Promise<Record<string, number>> {
|
): Promise<Record<string, number>> {
|
||||||
if (tabs.length === 0) return {};
|
if (tabs.length === 0) return {};
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const batch of batched(tabs, this.getMaxCallsInRequest())) {
|
||||||
try {
|
try {
|
||||||
const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => {
|
const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => {
|
||||||
const conditions: Record<string, unknown>[] = [
|
const conditions: Record<string, unknown>[] = [
|
||||||
{ inMailbox: mailboxId },
|
{ inMailbox: mailboxId },
|
||||||
{ notKeyword: "$seen" },
|
{ notKeyword: "$seen" },
|
||||||
@@ -1340,17 +1351,17 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
const result: Record<string, number> = {};
|
for (let i = 0; i < batch.length; i++) {
|
||||||
for (let i = 0; i < tabs.length; i++) {
|
result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
|
||||||
result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
|
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get category tab counts:', error);
|
console.error('Failed to get category tab counts:', error);
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
|
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
@@ -1464,11 +1475,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
|
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
|
||||||
if (emailIds.length === 0) return;
|
if (emailIds.length === 0) return;
|
||||||
|
|
||||||
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
|
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||||
|
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read }]));
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
||||||
await this.request([
|
await this.request([
|
||||||
@@ -1529,11 +1542,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
*/
|
*/
|
||||||
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
|
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
|
||||||
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
|
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
|
||||||
const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }]));
|
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||||
|
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||||
// Query all email IDs that have the old keyword
|
// Query all email IDs that have the old keyword
|
||||||
@@ -1562,9 +1577,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (allIds.length === 0) return 0;
|
if (allIds.length === 0) return 0;
|
||||||
|
|
||||||
// Batch update: remove old keyword, add new keyword using per-property patches
|
// Batch update: remove old keyword, add new keyword using per-property patches
|
||||||
const updateBatchSize = 50;
|
for (const batch of batched(allIds, this.getMaxObjectsInSet())) {
|
||||||
for (let i = 0; i < allIds.length; i += updateBatchSize) {
|
|
||||||
const batch = allIds.slice(i, i + updateBatchSize);
|
|
||||||
const update: Record<string, Record<string, boolean | null>> = {};
|
const update: Record<string, Record<string, boolean | null>> = {};
|
||||||
for (const id of batch) {
|
for (const id of batch) {
|
||||||
update[id] = {
|
update[id] = {
|
||||||
@@ -1608,13 +1621,15 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
|
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
|
||||||
if (emailIds.length === 0) return;
|
if (emailIds.length === 0) return;
|
||||||
|
|
||||||
|
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", {
|
["Email/set", {
|
||||||
accountId: accountId || this.accountId,
|
accountId: accountId || this.accountId,
|
||||||
destroy: emailIds,
|
destroy: batch,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||||
if (emailIds.length === 0) return;
|
if (emailIds.length === 0) return;
|
||||||
@@ -1624,11 +1639,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (markAsRead) patch["keywords/$seen"] = true;
|
if (markAsRead) patch["keywords/$seen"] = true;
|
||||||
return patch;
|
return patch;
|
||||||
};
|
};
|
||||||
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
|
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||||
|
const updates = Object.fromEntries(batch.map(id => [id, buildPatch()]));
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async batchArchiveEmails(
|
async batchArchiveEmails(
|
||||||
emails: Array<{ id: string; receivedAt: string }>,
|
emails: Array<{ id: string; receivedAt: string }>,
|
||||||
@@ -1705,16 +1722,33 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
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;
|
const hasCreates = Object.keys(createEntries).length > 0;
|
||||||
if (hasCreates) {
|
let createdIdFor: Record<string, string> = {};
|
||||||
|
|
||||||
|
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];
|
||||||
|
});
|
||||||
|
|
||||||
|
const methodCalls: JMAPMethodCall[] = [];
|
||||||
|
const withCreates = hasCreates && i === 0;
|
||||||
|
if (withCreates) {
|
||||||
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
||||||
}
|
}
|
||||||
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
|
methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]);
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
if (hasCreates) {
|
if (withCreates) {
|
||||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||||
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
||||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||||
@@ -1725,9 +1759,15 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (err.description) parts.push(err.description);
|
if (err.description) parts.push(err.description);
|
||||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
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 = hasCreates ? 1 : 0;
|
const emailIdx = withCreates ? 1 : 0;
|
||||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||||
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
||||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||||
@@ -1736,6 +1776,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
@@ -1758,15 +1799,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const batchSize = Math.min(500, this.getMaxObjectsInSet());
|
||||||
let totalDestroyed = 0;
|
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([
|
const response = await this.request([
|
||||||
["Email/query", {
|
["Email/query", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
filter: { inMailbox: mailboxId },
|
filter: { inMailbox: mailboxId },
|
||||||
limit: 500,
|
limit: batchSize,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/set", {
|
["Email/set", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -1776,10 +1821,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
const queryResult = response.methodResponses?.[0]?.[1];
|
const queryResult = response.methodResponses?.[0]?.[1];
|
||||||
const setResult = response.methodResponses?.[1]?.[1];
|
const setResult = response.methodResponses?.[1]?.[1];
|
||||||
|
const found: string[] = queryResult?.ids || [];
|
||||||
const destroyed = setResult?.destroyed?.length || 0;
|
const destroyed = setResult?.destroyed?.length || 0;
|
||||||
totalDestroyed += destroyed;
|
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;
|
return totalDestroyed;
|
||||||
@@ -1787,6 +1838,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
|
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const pageSize = Math.min(500, this.getMaxObjectsInSet());
|
||||||
let totalMarked = 0;
|
let totalMarked = 0;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
|
|
||||||
@@ -1801,7 +1853,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
{ notKeyword: "$seen" },
|
{ notKeyword: "$seen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
limit: 500,
|
limit: pageSize,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1817,7 +1869,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
totalMarked += ids.length;
|
totalMarked += ids.length;
|
||||||
hasMore = ids.length === 500;
|
hasMore = ids.length === pageSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalMarked;
|
return totalMarked;
|
||||||
@@ -1826,6 +1878,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
|
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
const excludeSet = new Set(excludeMailboxIds);
|
const excludeSet = new Set(excludeMailboxIds);
|
||||||
|
const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet());
|
||||||
let totalMarked = 0;
|
let totalMarked = 0;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
let position = 0;
|
let position = 0;
|
||||||
@@ -1835,7 +1888,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
["Email/query", {
|
["Email/query", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
filter: { notKeyword: "$seen" },
|
filter: { notKeyword: "$seen" },
|
||||||
limit: 500,
|
limit: pageSize,
|
||||||
position,
|
position,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
@@ -1871,7 +1924,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
totalMarked += targetIds.length;
|
totalMarked += targetIds.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMore = ids.length === 500;
|
hasMore = ids.length === pageSize;
|
||||||
position += ids.length;
|
position += ids.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2183,14 +2236,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (threadIds.length === 0) return [];
|
if (threadIds.length === 0) return [];
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const threads: Thread[] = [];
|
||||||
|
|
||||||
|
for (const batchIds of batched(threadIds, this.getMaxObjectsInGet())) {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"],
|
["Thread/get", { accountId: targetAccountId, ids: batchIds }, "0"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||||||
return (response.methodResponses[0][1].list || []) as Thread[];
|
threads.push(...((response.methodResponses[0][1].list || []) as Thread[]));
|
||||||
}
|
}
|
||||||
return [];
|
}
|
||||||
|
|
||||||
|
return threads;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get threads:', error);
|
console.error('Failed to get threads:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -2205,10 +2263,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emails: Email[] = [];
|
||||||
|
|
||||||
|
for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
ids: thread.emailIds,
|
ids: batchIds,
|
||||||
properties: [
|
properties: [
|
||||||
...EMAIL_LIST_PROPERTIES,
|
...EMAIL_LIST_PROPERTIES,
|
||||||
"textBody", "htmlBody", "bodyValues",
|
"textBody", "htmlBody", "bodyValues",
|
||||||
@@ -2223,8 +2284,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Email/get") {
|
if (response.methodResponses?.[0]?.[0] === "Email/get") {
|
||||||
const emails = response.methodResponses[0][1].list || [];
|
emails.push(...(response.methodResponses[0][1].list || []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.length > 0) {
|
||||||
if (accountId && accountId !== this.accountId) {
|
if (accountId && accountId !== this.accountId) {
|
||||||
namespaceMailboxIds(emails, accountId);
|
namespaceMailboxIds(emails, accountId);
|
||||||
}
|
}
|
||||||
@@ -3596,6 +3660,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return coreCapability?.maxObjectsInGet || 500;
|
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 {
|
getMaxDelayedSend(accountId?: string): number {
|
||||||
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
||||||
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
||||||
@@ -5050,16 +5119,21 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
|
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
||||||
|
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
const indexed = events.map((event, index) => ({ event, index }));
|
||||||
|
|
||||||
|
for (const batch of batched(indexed, this.getMaxObjectsInSet())) {
|
||||||
// Build the create map: { "new-0": event0, "new-1": event1, ... }
|
// Build the create map: { "new-0": event0, "new-1": event1, ... }
|
||||||
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
||||||
for (let i = 0; i < events.length; i++) {
|
for (const { event, index } of batch) {
|
||||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent;
|
||||||
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
|
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
|
||||||
createMap[`new-${i}`] = clean;
|
createMap[`new-${index}`] = clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
|
||||||
|
|
||||||
// Never emit iMIP scheduling messages when importing. Imported events often
|
// Never emit iMIP scheduling messages when importing. Imported events often
|
||||||
// carry an organizer/participants where the current user is the organizer;
|
// carry an organizer/participants where the current user is the organizer;
|
||||||
// without this, Stalwart tries to send invitation emails to every attendee
|
// without this, Stalwart tries to send invitation emails to every attendee
|
||||||
@@ -5070,13 +5144,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
|
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
const createdIds: string[] = [];
|
|
||||||
const failed: string[] = [];
|
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||||
const result = response.methodResponses[0][1];
|
const result = response.methodResponses[0][1];
|
||||||
for (let i = 0; i < events.length; i++) {
|
for (const { index } of batch) {
|
||||||
const key = `new-${i}`;
|
const key = `new-${index}`;
|
||||||
if (result.created?.[key]?.id) {
|
if (result.created?.[key]?.id) {
|
||||||
createdIds.push(result.created[key].id);
|
createdIds.push(result.created[key].id);
|
||||||
} else if (result.notCreated?.[key]) {
|
} else if (result.notCreated?.[key]) {
|
||||||
@@ -5085,26 +5156,30 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (createdIds.length === 0) {
|
if (createdIds.length === 0) {
|
||||||
return { created: [], failed };
|
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 refetchTimeZone = getUserTimeZone();
|
||||||
|
const createdEvents: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) {
|
||||||
const getResponse = await this.request([
|
const getResponse = await this.request([
|
||||||
["CalendarEvent/get", {
|
["CalendarEvent/get", {
|
||||||
accountId,
|
accountId,
|
||||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||||
ids: createdIds,
|
ids: batchIds,
|
||||||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
let createdEvents: CalendarEvent[] = [];
|
|
||||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||||
const list = getResponse.methodResponses[0][1].list || [];
|
const list = getResponse.methodResponses[0][1].list || [];
|
||||||
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
|
createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('calendar', 'CalendarEvent/batchCreate result', {
|
debug.log('calendar', 'CalendarEvent/batchCreate result', {
|
||||||
@@ -5255,18 +5330,20 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
||||||
|
|
||||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const response = await this.request([
|
|
||||||
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
|
||||||
], this.calendarUsing());
|
|
||||||
|
|
||||||
const destroyed: string[] = [];
|
const destroyed: string[] = [];
|
||||||
const notDestroyed: string[] = [];
|
const notDestroyed: string[] = [];
|
||||||
|
|
||||||
|
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") {
|
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||||
const result = response.methodResponses[0][1];
|
const result = response.methodResponses[0][1];
|
||||||
if (result.destroyed) destroyed.push(...result.destroyed);
|
if (result.destroyed) destroyed.push(...result.destroyed);
|
||||||
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { destroyed, notDestroyed };
|
return { destroyed, notDestroyed };
|
||||||
}
|
}
|
||||||
@@ -5524,7 +5601,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static FILE_NODE_PROPERTIES = [
|
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
|
// Stalwart omits shareWith/myRights from FileNode/get unless requested
|
||||||
// explicitly, so the share dialog and indicators can't see existing
|
// explicitly, so the share dialog and indicators can't see existing
|
||||||
// shares without naming them here (same as CALENDAR_PROPERTIES).
|
// shares without naming them here (same as CALENDAR_PROPERTIES).
|
||||||
@@ -5778,12 +5855,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
* throws for per-node failures (only for a whole-method error).
|
* 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> }> {
|
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||||
const ids = Object.keys(updates);
|
const entries = Object.entries(updates);
|
||||||
if (ids.length === 0) return { updated: [], notUpdated: {} };
|
if (entries.length === 0) return { updated: [], notUpdated: {} };
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
|
const updated: string[] = [];
|
||||||
|
const notUpdated: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const batch of batched(entries, this.getMaxObjectsInSet())) {
|
||||||
const response = await this.request(
|
const response = await this.request(
|
||||||
[["FileNode/set", { accountId, update: updates }, "fns0"]],
|
[["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]],
|
||||||
this.fileUsing(),
|
this.fileUsing(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5794,24 +5875,27 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||||||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||||||
const notUpdated: Record<string, string> = {};
|
|
||||||
for (const id of Object.keys(notUpdatedMap)) {
|
for (const id of Object.keys(notUpdatedMap)) {
|
||||||
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||||||
}
|
}
|
||||||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||||||
const updated = Object.keys(updatedMap).length > 0
|
updated.push(...(Object.keys(updatedMap).length > 0
|
||||||
? Object.keys(updatedMap)
|
? Object.keys(updatedMap)
|
||||||
: ids.filter(id => !(id in notUpdated));
|
: batch.map(([id]) => id).filter(id => !(id in notUpdated))));
|
||||||
|
}
|
||||||
|
|
||||||
return { updated, notUpdated };
|
return { updated, notUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
|
||||||
|
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||||||
const response = await this.request(
|
const response = await this.request(
|
||||||
[["FileNode/set", {
|
[["FileNode/set", {
|
||||||
accountId,
|
accountId,
|
||||||
destroy: ids,
|
destroy: batch,
|
||||||
onDestroyRemoveChildren: true,
|
onDestroyRemoveChildren: true,
|
||||||
}, "fns0"]],
|
}, "fns0"]],
|
||||||
this.fileUsing(),
|
this.fileUsing(),
|
||||||
@@ -5830,10 +5914,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
destroyed.push(...(result[1].destroyed || []));
|
||||||
destroyed: result[1].destroyed || [],
|
}
|
||||||
notDestroyed: [],
|
|
||||||
};
|
return { destroyed, notDestroyed: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
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
|
* a shared mailbox owned by another user). When omitted, falls back to the
|
||||||
* client's own primary account.
|
* 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(
|
async importRawEmail(
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
mailboxIds: Record<string, boolean>,
|
mailboxIds: Record<string, boolean>,
|
||||||
|
|||||||
@@ -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
@@ -835,7 +835,11 @@ export interface FileNode {
|
|||||||
blobId: string | null;
|
blobId: string | null;
|
||||||
size: number;
|
size: number;
|
||||||
created: string;
|
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
|
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||||
// filenode capability and the properties are explicitly requested. A node is
|
// filenode capability and the properties are explicitly requested. A node is
|
||||||
// shared-out when `shareWith` has entries; `myRights` describes what the
|
// shared-out when `shareWith` has entries; `myRights` describes what the
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -470,22 +470,21 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
|
|||||||
|
|
||||||
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
// ─── 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.
|
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
||||||
* This secret is typically used as a local master encryption key.
|
* This secret is typically used as a local master encryption key.
|
||||||
*/
|
*/
|
||||||
async function doGetOrCreatePRF(
|
async function doGetOrCreatePRF(
|
||||||
masterCredentialIdBytes: number[] | undefined,
|
masterCredentialIdBytes: number[] | undefined,
|
||||||
|
pluginId: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
): Promise<{ credentialId: number[]; prfSecret: number[] } | 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) ──────────────────
|
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
||||||
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
||||||
const credentialId = new Uint8Array(masterCredentialIdBytes).buffer;
|
const credentialId = new Uint8Array(masterCredentialIdBytes).buffer;
|
||||||
@@ -496,18 +495,18 @@ async function doGetOrCreatePRF(
|
|||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
allowCredentials: [{ type: "public-key", id: credentialId }],
|
allowCredentials: [{ type: "public-key", id: credentialId }],
|
||||||
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
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;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
// Extract the derived symmetric key from the authenticator's output
|
// Extract the derived symmetric key from the authenticator's output
|
||||||
const outputs = assertion.getClientExtensionResults();
|
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.';
|
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: masterCredentialIdBytes,
|
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.)
|
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
||||||
userVerification: "required"
|
userVerification: "required"
|
||||||
},
|
},
|
||||||
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
|
extensions: { prf: {} } // Request PRF extension support from the authenticator
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
const outputs = credential.getClientExtensionResults();
|
const outputs = credential.getClientExtensionResults();
|
||||||
|
|
||||||
// Ensure the authenticator successfully enabled and supports the PRF extension
|
// Ensure the authenticator successfully enabled and supports the PRF extension
|
||||||
const isPrfEnabled = (outputs as any).prf?.enabled;
|
const isPrfEnabled = (outputs).prf?.enabled;
|
||||||
if (!isPrfEnabled) {
|
if (!isPrfEnabled) {
|
||||||
return 'The authenticator does not support or has rejected the PRF extension.';
|
return 'The authenticator does not support or has rejected the PRF extension.';
|
||||||
}
|
}
|
||||||
@@ -556,20 +555,20 @@ async function doGetOrCreatePRF(
|
|||||||
userVerification: "required",
|
userVerification: "required",
|
||||||
extensions: {
|
extensions: {
|
||||||
prf: { eval: { first: PRF_SALT } }
|
prf: { eval: { first: PRF_SALT } }
|
||||||
} as any
|
}
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
const assertionOutputs = assertion.getClientExtensionResults();
|
const assertionOutputs = assertion.getClientExtensionResults();
|
||||||
|
|
||||||
const prfSecret = (assertionOutputs as any).prf?.results?.first;
|
const prfSecret = (assertionOutputs).prf?.results?.first;
|
||||||
if (!prfSecret) {
|
if (!prfSecret) {
|
||||||
return 'Cannot get PRF secret from existing credential.';
|
return 'Cannot get PRF secret from existing credential.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
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.get' : return getFile(args[0] as string);
|
||||||
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
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.get': return doContactGet(args[0] as string);
|
||||||
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
settings: { ...manifest.settings },
|
settings: { ...manifest.settings },
|
||||||
},
|
},
|
||||||
webauthn: {
|
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: {
|
storage: {
|
||||||
get: (key: string) => callApi('storage.get', [key]),
|
get: (key: string) => callApi('storage.get', [key]),
|
||||||
|
|||||||
@@ -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
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
|
|||||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
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.
|
* 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 [];
|
if (!keywords) return [];
|
||||||
const tags: string[] = [];
|
const tags = new Set<string>();
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||||
tags.push(
|
tags.add(
|
||||||
key.startsWith(KEYWORD_PREFIX)
|
key.startsWith(KEYWORD_PREFIX)
|
||||||
? key.slice(KEYWORD_PREFIX.length)
|
? key.slice(KEYWORD_PREFIX.length)
|
||||||
: key.slice(KEYWORD_PREFIX_LEGACY.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.
|
* 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 {
|
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
||||||
const tags = getEmailColorTags(keywords);
|
const tags = getEmailTagIds(keywords);
|
||||||
return tags.length > 0 ? tags[0] : null;
|
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) {
|
for (const email of emails) {
|
||||||
const color = getEmailColorTag(email.keywords);
|
const color = getEmailTagId(email.keywords);
|
||||||
if (color) return color;
|
if (color) return color;
|
||||||
}
|
}
|
||||||
return null;
|
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
@@ -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 hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
|
||||||
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
|
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;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -882,7 +884,11 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
const suffix = findKind("generation", "suffix");
|
const suffix = findKind("generation", "suffix");
|
||||||
const additional = findKind("given2", "additional", "middle");
|
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) {
|
if (fn) {
|
||||||
lines.push(`FN:${encodeValue(fn)}`);
|
lines.push(`FN:${encodeValue(fn)}`);
|
||||||
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "إعادة تعيين",
|
"demo_reset": "إعادة تعيين",
|
||||||
"demo_tour": "جولة",
|
"demo_tour": "جولة",
|
||||||
"tags": "الوسوم",
|
"tags": "الوسوم",
|
||||||
|
"show_all_tags": "إظهار الكل ({count})",
|
||||||
|
"show_fewer_tags": "إظهار أقل",
|
||||||
"folders": "المجلدات",
|
"folders": "المجلدات",
|
||||||
"shared": "مشترك",
|
"shared": "مشترك",
|
||||||
"mail": "البريد",
|
"mail": "البريد",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "طباعة",
|
"print": "طباعة",
|
||||||
"view_source": "عرض المصدر",
|
"view_source": "عرض المصدر",
|
||||||
"export_email": "تصدير كملف .eml",
|
"export_email": "تصدير كملف .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "استيراد ملف .eml أو .zip",
|
"import_email": "استيراد ملف .eml أو .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": "تعيين وسم",
|
||||||
"tag": "وسم",
|
"tag": "وسم",
|
||||||
"more_actions": "المزيد من الإجراءات",
|
"more_actions": "المزيد من الإجراءات",
|
||||||
"previous": "السابق",
|
"previous": "السابق",
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
"move_to": "نقل إلى...",
|
"move_to": "نقل إلى...",
|
||||||
"remove_color": "إزالة الوسم",
|
"remove_tag": "إزالة الوسم",
|
||||||
|
"tag_filter_placeholder": "تصفية الوسوم",
|
||||||
|
"tag_no_matches": "لا توجد وسوم مطابقة",
|
||||||
"more_count": "+{count} أخرى",
|
"more_count": "+{count} أخرى",
|
||||||
"characters_count": "{count} حرفًا",
|
"characters_count": "{count} حرفًا",
|
||||||
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
|
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "معرّف الرسالة",
|
"message_id": "معرّف الرسالة",
|
||||||
"list_info": "معلومات القائمة"
|
"list_info": "معلومات القائمة"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "وسم لوني",
|
|
||||||
"red": "أحمر",
|
|
||||||
"orange": "برتقالي",
|
|
||||||
"yellow": "أصفر",
|
|
||||||
"green": "أخضر",
|
|
||||||
"blue": "أزرق",
|
|
||||||
"purple": "بنفسجي",
|
|
||||||
"pink": "وردي",
|
|
||||||
"none": "بلا"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "رد (r)",
|
"reply": "رد (r)",
|
||||||
"reply_all": "الرد على الجميع (a)",
|
"reply_all": "الرد على الجميع (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "الاسم المعروض",
|
"recipient_name_placeholder": "الاسم المعروض",
|
||||||
"autocomplete_search_server": "البحث في الخادم",
|
"autocomplete_search_server": "البحث في الخادم",
|
||||||
"autocomplete_searching": "جارٍ البحث...",
|
"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": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
|
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "وسوم البريد",
|
"title": "وسوم البريد",
|
||||||
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
|
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
|
||||||
"add_keyword": "إضافة وسم",
|
"add_keyword": "إضافة وسم",
|
||||||
"reset_defaults": "إعادة التعيين للافتراضي",
|
|
||||||
"label_field": "الاسم المعروض",
|
"label_field": "الاسم المعروض",
|
||||||
"label_placeholder": "مثال: عمل، شخصي، عاجل",
|
"label_placeholder": "مثال: عمل، شخصي، عاجل",
|
||||||
"id_field": "معرّف الوسم",
|
"id_field": "معرّف الوسم",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "إضافة",
|
"add": "إضافة",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "اختبار صوت الإشعار",
|
"test_sound": "اختبار صوت الإشعار",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"mark_as_spam": "الإبلاغ عن بريد مزعج",
|
"mark_as_spam": "الإبلاغ عن بريد مزعج",
|
||||||
"not_spam": "ليس مزعجًا",
|
"not_spam": "ليس مزعجًا",
|
||||||
"color_tag": "وسم",
|
"tag": "وسم",
|
||||||
"remove_color": "إزالة الوسم",
|
|
||||||
"items_selected": "{count} رسالة محددة",
|
"items_selected": "{count} رسالة محددة",
|
||||||
"edit_draft": "تعديل المسودة",
|
"edit_draft": "تعديل المسودة",
|
||||||
"cancel_scheduled_send": "إلغاء الإرسال",
|
"cancel_scheduled_send": "إلغاء الإرسال",
|
||||||
@@ -2337,6 +2376,9 @@
|
|||||||
"section_address_book": "الدليل",
|
"section_address_book": "الدليل",
|
||||||
"select_address_book": "اختر دليلًا...",
|
"select_address_book": "اختر دليلًا...",
|
||||||
"section_identity": "الاسم والهوية",
|
"section_identity": "الاسم والهوية",
|
||||||
|
"contact_type": "نوع جهة الاتصال",
|
||||||
|
"type_person": "شخص",
|
||||||
|
"type_organization": "المؤسسة",
|
||||||
"section_work": "العمل والمؤسسة",
|
"section_work": "العمل والمؤسسة",
|
||||||
"prefix": "اللقب",
|
"prefix": "اللقب",
|
||||||
"prefix_placeholder": "د.، أ.، السيدة",
|
"prefix_placeholder": "د.، أ.، السيدة",
|
||||||
@@ -2421,7 +2463,7 @@
|
|||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"creating": "جارٍ الإنشاء...",
|
"creating": "جارٍ الإنشاء...",
|
||||||
"updating": "جارٍ التحديث...",
|
"updating": "جارٍ التحديث...",
|
||||||
"name_required": "يلزم إدخال الاسم الأول أو اسم العائلة على الأقل",
|
"name_required": "أدخل اسمًا أول أو اسم عائلة أو مؤسسة",
|
||||||
"email_invalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
"email_invalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||||
"email_error_inline": "تنسيق البريد الإلكتروني غير صالح",
|
"email_error_inline": "تنسيق البريد الإلكتروني غير صالح",
|
||||||
"save_failed": "فشل حفظ جهة الاتصال",
|
"save_failed": "فشل حفظ جهة الاتصال",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetovat",
|
"demo_reset": "Resetovat",
|
||||||
"demo_tour": "Průvodce",
|
"demo_tour": "Průvodce",
|
||||||
"tags": "Štítky",
|
"tags": "Štítky",
|
||||||
|
"show_all_tags": "Zobrazit vše ({count})",
|
||||||
|
"show_fewer_tags": "Zobrazit méně",
|
||||||
"folders": "Složky",
|
"folders": "Složky",
|
||||||
"shared": "Sdílené",
|
"shared": "Sdílené",
|
||||||
"mail": "Pošta",
|
"mail": "Pošta",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Tisk",
|
"print": "Tisk",
|
||||||
"view_source": "Zobrazit zdrojový kód",
|
"view_source": "Zobrazit zdrojový kód",
|
||||||
"export_email": "Exportovat jako .eml",
|
"export_email": "Exportovat jako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importovat .eml nebo .zip",
|
"import_email": "Importovat .eml nebo .zip",
|
||||||
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
||||||
"email_source": "Zdrojový kód zprávy",
|
"email_source": "Zdrojový kód zprávy",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Zobrazit kontakt",
|
"view_contact": "Zobrazit kontakt",
|
||||||
"message_details": "Podrobnosti zprávy",
|
"message_details": "Podrobnosti zprávy",
|
||||||
"more_reply_options": "Další možnosti odpovědi",
|
"more_reply_options": "Další možnosti odpovědi",
|
||||||
"set_color": "Nastavit štítek",
|
"set_tag": "Nastavit štítek",
|
||||||
"tag": "Štítek",
|
"tag": "Štítek",
|
||||||
"more_actions": "Další akce",
|
"more_actions": "Další akce",
|
||||||
"previous": "Předchozí",
|
"previous": "Předchozí",
|
||||||
"next": "Další",
|
"next": "Další",
|
||||||
"move_to": "Přesunout do...",
|
"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",
|
"more_count": "+{count} dalších",
|
||||||
"characters_count": "{count} znaků",
|
"characters_count": "{count} znaků",
|
||||||
"quick_reply_placeholder": "Napsat rychlou odpověď...",
|
"quick_reply_placeholder": "Napsat rychlou odpověď...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "ID zprávy",
|
"message_id": "ID zprávy",
|
||||||
"list_info": "Informace o konferenci"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Odpovědět (r)",
|
"reply": "Odpovědět (r)",
|
||||||
"reply_all": "Odpovědět všem (a)",
|
"reply_all": "Odpovědět všem (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Zobrazované jméno",
|
"recipient_name_placeholder": "Zobrazované jméno",
|
||||||
"autocomplete_search_server": "Hledat na serveru",
|
"autocomplete_search_server": "Hledat na serveru",
|
||||||
"autocomplete_searching": "Hledání...",
|
"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."
|
"send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "E-mailové štítky",
|
"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.",
|
"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",
|
"add_keyword": "Přidat štítek",
|
||||||
"reset_defaults": "Obnovit výchozí",
|
|
||||||
"label_field": "Zobrazovaný název",
|
"label_field": "Zobrazovaný název",
|
||||||
"label_placeholder": "např. Práce, Osobní, Naliehavé",
|
"label_placeholder": "např. Práce, Osobní, Naliehavé",
|
||||||
"id_field": "ID štítku",
|
"id_field": "ID štítku",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Přidat",
|
"add": "Přidat",
|
||||||
"cancel": "Zrušit",
|
"cancel": "Zrušit",
|
||||||
"migrating": "Aktualizace štítku v existujících e-mailech…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Otestovat zvuk oznámení",
|
"test_sound": "Otestovat zvuk oznámení",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Odstranit",
|
"delete": "Odstranit",
|
||||||
"mark_as_spam": "Nahlásit spam",
|
"mark_as_spam": "Nahlásit spam",
|
||||||
"not_spam": "Není spam",
|
"not_spam": "Není spam",
|
||||||
"color_tag": "Štítek",
|
"tag": "Štítek",
|
||||||
"remove_color": "Odebrat štítek",
|
|
||||||
"items_selected": "{count} vybraných zpráv",
|
"items_selected": "{count} vybraných zpráv",
|
||||||
"edit_draft": "Upravit koncept",
|
"edit_draft": "Upravit koncept",
|
||||||
"cancel_scheduled_send": "Zrušit odeslání",
|
"cancel_scheduled_send": "Zrušit odeslání",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Adresář",
|
"section_address_book": "Adresář",
|
||||||
"select_address_book": "Vyberte adresář...",
|
"select_address_book": "Vyberte adresář...",
|
||||||
"section_identity": "Jméno a identita",
|
"section_identity": "Jméno a identita",
|
||||||
|
"contact_type": "Typ kontaktu",
|
||||||
|
"type_person": "Osoba",
|
||||||
|
"type_organization": "Organizace",
|
||||||
"section_work": "Práce a organizace",
|
"section_work": "Práce a organizace",
|
||||||
"prefix": "Titul",
|
"prefix": "Titul",
|
||||||
"prefix_placeholder": "Dr., Pan, Paní",
|
"prefix_placeholder": "Dr., Pan, Paní",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Zrušit",
|
"cancel": "Zrušit",
|
||||||
"creating": "Vytváření...",
|
"creating": "Vytváření...",
|
||||||
"updating": "Aktualizování...",
|
"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_invalid": "Zadejte platnou e-mailovou adresu",
|
||||||
"email_error_inline": "Neplatný formát e-mailové adresy",
|
"email_error_inline": "Neplatný formát e-mailové adresy",
|
||||||
"save_failed": "Uložení kontaktu selhalo",
|
"save_failed": "Uložení kontaktu selhalo",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Nulstil",
|
"demo_reset": "Nulstil",
|
||||||
"demo_tour": "Rundvisning",
|
"demo_tour": "Rundvisning",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Vis alle ({count})",
|
||||||
|
"show_fewer_tags": "Vis færre",
|
||||||
"folders": "Mapper",
|
"folders": "Mapper",
|
||||||
"shared": "Delt",
|
"shared": "Delt",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Udskriv",
|
"print": "Udskriv",
|
||||||
"view_source": "Vis kilde",
|
"view_source": "Vis kilde",
|
||||||
"export_email": "Eksportér som .eml",
|
"export_email": "Eksportér som .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importér .eml eller .zip",
|
"import_email": "Importér .eml eller .zip",
|
||||||
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
||||||
"email_source": "E-mail-kilde",
|
"email_source": "E-mail-kilde",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Vis kontakt",
|
"view_contact": "Vis kontakt",
|
||||||
"message_details": "Beskeddetaljer",
|
"message_details": "Beskeddetaljer",
|
||||||
"more_reply_options": "Flere svar-muligheder",
|
"more_reply_options": "Flere svar-muligheder",
|
||||||
"set_color": "Sæt tag",
|
"set_tag": "Sæt tag",
|
||||||
"tag": "Tag",
|
"tag": "Tag",
|
||||||
"more_actions": "Flere handlinger",
|
"more_actions": "Flere handlinger",
|
||||||
"previous": "Forrige",
|
"previous": "Forrige",
|
||||||
"next": "Næste",
|
"next": "Næste",
|
||||||
"move_to": "Flyt til...",
|
"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",
|
"more_count": "+{count} mere",
|
||||||
"characters_count": "{count} tegn",
|
"characters_count": "{count} tegn",
|
||||||
"quick_reply_placeholder": "Skriv et hurtigt svar...",
|
"quick_reply_placeholder": "Skriv et hurtigt svar...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "Besked-ID",
|
"message_id": "Besked-ID",
|
||||||
"list_info": "Listeinformation"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Svar (r)",
|
"reply": "Svar (r)",
|
||||||
"reply_all": "Svar alle (a)",
|
"reply_all": "Svar alle (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Visningsnavn",
|
"recipient_name_placeholder": "Visningsnavn",
|
||||||
"autocomplete_search_server": "Søg på serveren",
|
"autocomplete_search_server": "Søg på serveren",
|
||||||
"autocomplete_searching": "Søger...",
|
"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."
|
"send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "E-mail-tags",
|
"title": "E-mail-tags",
|
||||||
"description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.",
|
"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",
|
"add_keyword": "Tilføj tag",
|
||||||
"reset_defaults": "Nulstil til standard",
|
|
||||||
"label_field": "Visningsnavn",
|
"label_field": "Visningsnavn",
|
||||||
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
|
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
|
||||||
"id_field": "Tag-ID",
|
"id_field": "Tag-ID",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "Tilføj",
|
"add": "Tilføj",
|
||||||
"cancel": "Annuller",
|
"cancel": "Annuller",
|
||||||
"migrating": "Opdaterer tag på eksisterende e-mails…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Test notifikationslyd",
|
"test_sound": "Test notifikationslyd",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
"mark_as_spam": "Rapportér spam",
|
"mark_as_spam": "Rapportér spam",
|
||||||
"not_spam": "Ikke spam",
|
"not_spam": "Ikke spam",
|
||||||
"color_tag": "Tag",
|
"tag": "Tag",
|
||||||
"remove_color": "Fjern tag",
|
|
||||||
"items_selected": "{count} e-mails valgt",
|
"items_selected": "{count} e-mails valgt",
|
||||||
"edit_draft": "Redigér kladde",
|
"edit_draft": "Redigér kladde",
|
||||||
"cancel_scheduled_send": "Annuller afsendelse",
|
"cancel_scheduled_send": "Annuller afsendelse",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Adressebog",
|
"section_address_book": "Adressebog",
|
||||||
"select_address_book": "Vælg en adressebog...",
|
"select_address_book": "Vælg en adressebog...",
|
||||||
"section_identity": "Navn & identitet",
|
"section_identity": "Navn & identitet",
|
||||||
|
"contact_type": "Kontakttype",
|
||||||
|
"type_person": "Person",
|
||||||
|
"type_organization": "Organisation",
|
||||||
"section_work": "Arbejde & organisation",
|
"section_work": "Arbejde & organisation",
|
||||||
"prefix": "Præfiks",
|
"prefix": "Præfiks",
|
||||||
"prefix_placeholder": "Dr., hr., fru",
|
"prefix_placeholder": "Dr., hr., fru",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Annuller",
|
"cancel": "Annuller",
|
||||||
"creating": "Opretter...",
|
"creating": "Opretter...",
|
||||||
"updating": "Opdaterer...",
|
"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_invalid": "Indtast en gyldig e-mailadresse",
|
||||||
"email_error_inline": "Ugyldigt e-mailformat",
|
"email_error_inline": "Ugyldigt e-mailformat",
|
||||||
"save_failed": "Kunne ikke gemme kontakt",
|
"save_failed": "Kunne ikke gemme kontakt",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Zurücksetzen",
|
"demo_reset": "Zurücksetzen",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Alle anzeigen ({count})",
|
||||||
|
"show_fewer_tags": "Weniger anzeigen",
|
||||||
"folders": "Ordner",
|
"folders": "Ordner",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Drucken",
|
"print": "Drucken",
|
||||||
"view_source": "Quelltext anzeigen",
|
"view_source": "Quelltext anzeigen",
|
||||||
"export_email": "Als .eml exportieren",
|
"export_email": "Als .eml exportieren",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml oder .zip importieren",
|
"import_email": ".eml oder .zip importieren",
|
||||||
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
||||||
"email_source": "E-Mail-Quelltext",
|
"email_source": "E-Mail-Quelltext",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Kontakt anzeigen",
|
"view_contact": "Kontakt anzeigen",
|
||||||
"message_details": "Nachrichtendetails",
|
"message_details": "Nachrichtendetails",
|
||||||
"more_reply_options": "Weitere Antwortoptionen",
|
"more_reply_options": "Weitere Antwortoptionen",
|
||||||
"set_color": "Label setzen",
|
"set_tag": "Label setzen",
|
||||||
"tag": "Label",
|
"tag": "Label",
|
||||||
"more_actions": "Weitere Aktionen",
|
"more_actions": "Weitere Aktionen",
|
||||||
"move_to": "Verschieben nach...",
|
"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",
|
"more_count": "+{count} weitere",
|
||||||
"characters_count": "{count} Zeichen",
|
"characters_count": "{count} Zeichen",
|
||||||
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "Nachrichten-ID",
|
"message_id": "Nachrichten-ID",
|
||||||
"list_info": "Listeninformationen"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Antworten",
|
"reply": "Antworten",
|
||||||
"reply_all": "Allen antworten (a)",
|
"reply_all": "Allen antworten (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Anzeigename",
|
"recipient_name_placeholder": "Anzeigename",
|
||||||
"autocomplete_search_server": "Auf dem Server suchen",
|
"autocomplete_search_server": "Auf dem Server suchen",
|
||||||
"autocomplete_searching": "Suche läuft...",
|
"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."
|
"send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "E-Mail-Labels",
|
"title": "E-Mail-Labels",
|
||||||
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
|
"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",
|
"add_keyword": "Label hinzufügen",
|
||||||
"reset_defaults": "Auf Standard zurücksetzen",
|
|
||||||
"label_field": "Anzeigename",
|
"label_field": "Anzeigename",
|
||||||
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
||||||
"id_field": "Label-ID",
|
"id_field": "Label-ID",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Benachrichtigungston testen",
|
"test_sound": "Benachrichtigungston testen",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"mark_as_spam": "Spam melden",
|
"mark_as_spam": "Spam melden",
|
||||||
"not_spam": "Kein Spam",
|
"not_spam": "Kein Spam",
|
||||||
"color_tag": "Label",
|
"tag": "Label",
|
||||||
"remove_color": "Label entfernen",
|
|
||||||
"items_selected": "{count} E-Mails ausgewählt",
|
"items_selected": "{count} E-Mails ausgewählt",
|
||||||
"edit_draft": "Entwurf bearbeiten",
|
"edit_draft": "Entwurf bearbeiten",
|
||||||
"cancel_scheduled_send": "Senden abbrechen",
|
"cancel_scheduled_send": "Senden abbrechen",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Verzeichnis",
|
"section_address_book": "Verzeichnis",
|
||||||
"select_address_book": "Verzeichnis auswählen...",
|
"select_address_book": "Verzeichnis auswählen...",
|
||||||
"section_identity": "Name & Identität",
|
"section_identity": "Name & Identität",
|
||||||
|
"contact_type": "Kontakttyp",
|
||||||
|
"type_person": "Person",
|
||||||
|
"type_organization": "Organisation",
|
||||||
"section_work": "Beruf & Organisation",
|
"section_work": "Beruf & Organisation",
|
||||||
"prefix": "Anrede",
|
"prefix": "Anrede",
|
||||||
"prefix_placeholder": "Dr., Herr, Frau",
|
"prefix_placeholder": "Dr., Herr, Frau",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"creating": "Wird erstellt...",
|
"creating": "Wird erstellt...",
|
||||||
"updating": "Wird aktualisiert...",
|
"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_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||||
"email_error_inline": "Ungültiges E-Mail-Format",
|
"email_error_inline": "Ungültiges E-Mail-Format",
|
||||||
"save_failed": "Kontakt konnte nicht gespeichert werden",
|
"save_failed": "Kontakt konnte nicht gespeichert werden",
|
||||||
|
|||||||
+61
-19
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Reset",
|
"demo_reset": "Reset",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Show all ({count})",
|
||||||
|
"show_fewer_tags": "Show less",
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
"shared": "Shared",
|
"shared": "Shared",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Print",
|
"print": "Print",
|
||||||
"view_source": "View source",
|
"view_source": "View source",
|
||||||
"export_email": "Export as .eml",
|
"export_email": "Export as .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Import .eml or .zip",
|
"import_email": "Import .eml or .zip",
|
||||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||||
"email_source": "Email Source",
|
"email_source": "Email Source",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "View contact",
|
"view_contact": "View contact",
|
||||||
"message_details": "Message Details",
|
"message_details": "Message Details",
|
||||||
"more_reply_options": "More reply options",
|
"more_reply_options": "More reply options",
|
||||||
"set_color": "Set tag",
|
"set_tag": "Set tag",
|
||||||
"tag": "Tag",
|
"tag": "Tag",
|
||||||
"more_actions": "More actions",
|
"more_actions": "More actions",
|
||||||
"previous": "Prev",
|
"previous": "Prev",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"move_to": "Move to...",
|
"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",
|
"more_count": "+{count} more",
|
||||||
"characters_count": "{count} characters",
|
"characters_count": "{count} characters",
|
||||||
"quick_reply_placeholder": "Write a quick reply...",
|
"quick_reply_placeholder": "Write a quick reply...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "Message ID",
|
"message_id": "Message ID",
|
||||||
"list_info": "List Information"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Reply (r)",
|
"reply": "Reply (r)",
|
||||||
"reply_all": "Reply All (a)",
|
"reply_all": "Reply All (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Display name",
|
"recipient_name_placeholder": "Display name",
|
||||||
"autocomplete_search_server": "Search the server",
|
"autocomplete_search_server": "Search the server",
|
||||||
"autocomplete_searching": "Searching...",
|
"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."
|
"send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -984,9 +1010,8 @@
|
|||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
"title": "Email Tags",
|
"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",
|
"add_keyword": "Add Tag",
|
||||||
"reset_defaults": "Reset to Defaults",
|
|
||||||
"label_field": "Display Name",
|
"label_field": "Display Name",
|
||||||
"label_placeholder": "e.g. Work, Personal, Urgent",
|
"label_placeholder": "e.g. Work, Personal, Urgent",
|
||||||
"id_field": "Tag ID",
|
"id_field": "Tag ID",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"migrating": "Updating tag on existing emails…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Test notification sound",
|
"test_sound": "Test notification sound",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"mark_as_spam": "Report spam",
|
"mark_as_spam": "Report spam",
|
||||||
"not_spam": "Not spam",
|
"not_spam": "Not spam",
|
||||||
"color_tag": "Tag",
|
"tag": "Tag",
|
||||||
"remove_color": "Remove tag",
|
|
||||||
"items_selected": "{count} emails selected",
|
"items_selected": "{count} emails selected",
|
||||||
"edit_draft": "Edit Draft",
|
"edit_draft": "Edit Draft",
|
||||||
"cancel_scheduled_send": "Cancel send",
|
"cancel_scheduled_send": "Cancel send",
|
||||||
@@ -2337,6 +2376,9 @@
|
|||||||
"section_address_book": "Directory",
|
"section_address_book": "Directory",
|
||||||
"select_address_book": "Select a directory...",
|
"select_address_book": "Select a directory...",
|
||||||
"section_identity": "Name & Identity",
|
"section_identity": "Name & Identity",
|
||||||
|
"contact_type": "Contact type",
|
||||||
|
"type_person": "Person",
|
||||||
|
"type_organization": "Organization",
|
||||||
"section_work": "Work & Organization",
|
"section_work": "Work & Organization",
|
||||||
"prefix": "Prefix",
|
"prefix": "Prefix",
|
||||||
"prefix_placeholder": "Dr., Mr., Mrs.",
|
"prefix_placeholder": "Dr., Mr., Mrs.",
|
||||||
@@ -2421,7 +2463,7 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
"updating": "Updating...",
|
"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_invalid": "Please enter a valid email address",
|
||||||
"email_error_inline": "Invalid email format",
|
"email_error_inline": "Invalid email format",
|
||||||
"save_failed": "Failed to save contact",
|
"save_failed": "Failed to save contact",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Restablecer",
|
"demo_reset": "Restablecer",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
|
"show_all_tags": "Mostrar todo ({count})",
|
||||||
|
"show_fewer_tags": "Mostrar menos",
|
||||||
"folders": "Carpetas",
|
"folders": "Carpetas",
|
||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
"nav_label": "Navegación",
|
"nav_label": "Navegación",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código fuente",
|
"view_source": "Ver código fuente",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importar .eml o .zip",
|
"import_email": "Importar .eml o .zip",
|
||||||
"keyboard_shortcuts": "Atajos de teclado (?)",
|
"keyboard_shortcuts": "Atajos de teclado (?)",
|
||||||
"email_source": "Código Fuente del Correo",
|
"email_source": "Código Fuente del Correo",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Ver contacto",
|
"view_contact": "Ver contacto",
|
||||||
"message_details": "Detalles del Mensaje",
|
"message_details": "Detalles del Mensaje",
|
||||||
"more_reply_options": "Más opciones de respuesta",
|
"more_reply_options": "Más opciones de respuesta",
|
||||||
"set_color": "Establecer etiqueta",
|
"set_tag": "Establecer etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Más acciones",
|
"more_actions": "Más acciones",
|
||||||
"move_to": "Mover a...",
|
"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",
|
"more_count": "+{count} más",
|
||||||
"characters_count": "{count} caracteres",
|
"characters_count": "{count} caracteres",
|
||||||
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID del Mensaje",
|
"message_id": "ID del Mensaje",
|
||||||
"list_info": "Información de Lista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
"reply_all": "Responder a todos (a)",
|
"reply_all": "Responder a todos (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Nombre para mostrar",
|
"recipient_name_placeholder": "Nombre para mostrar",
|
||||||
"autocomplete_search_server": "Buscar en el servidor",
|
"autocomplete_search_server": "Buscar en el servidor",
|
||||||
"autocomplete_searching": "Buscando...",
|
"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."
|
"send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Etiquetas de correo",
|
"title": "Etiquetas de correo",
|
||||||
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
|
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
|
||||||
"add_keyword": "Añadir etiqueta",
|
"add_keyword": "Añadir etiqueta",
|
||||||
"reset_defaults": "Restablecer valores predeterminados",
|
|
||||||
"label_field": "Nombre para mostrar",
|
"label_field": "Nombre para mostrar",
|
||||||
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
||||||
"id_field": "ID de etiqueta",
|
"id_field": "ID de etiqueta",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"migrating": "Actualizando etiqueta en correos existentes…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Probar sonido de notificación",
|
"test_sound": "Probar sonido de notificación",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"mark_as_spam": "Reportar spam",
|
"mark_as_spam": "Reportar spam",
|
||||||
"not_spam": "No es spam",
|
"not_spam": "No es spam",
|
||||||
"color_tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"remove_color": "Eliminar etiqueta",
|
|
||||||
"items_selected": "{count} correos seleccionados",
|
"items_selected": "{count} correos seleccionados",
|
||||||
"edit_draft": "Editar borrador",
|
"edit_draft": "Editar borrador",
|
||||||
"cancel_scheduled_send": "Cancelar envío",
|
"cancel_scheduled_send": "Cancelar envío",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Directorio",
|
"section_address_book": "Directorio",
|
||||||
"select_address_book": "Seleccionar un directorio...",
|
"select_address_book": "Seleccionar un directorio...",
|
||||||
"section_identity": "Nombre e identidad",
|
"section_identity": "Nombre e identidad",
|
||||||
|
"contact_type": "Tipo de contacto",
|
||||||
|
"type_person": "Persona",
|
||||||
|
"type_organization": "Organización",
|
||||||
"section_work": "Trabajo y organización",
|
"section_work": "Trabajo y organización",
|
||||||
"prefix": "Prefijo",
|
"prefix": "Prefijo",
|
||||||
"prefix_placeholder": "Dr., Sr., Sra.",
|
"prefix_placeholder": "Dr., Sr., Sra.",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"creating": "Creando...",
|
"creating": "Creando...",
|
||||||
"updating": "Actualizando...",
|
"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_invalid": "Introduce una dirección de correo válida",
|
||||||
"email_error_inline": "Formato de correo inválido",
|
"email_error_inline": "Formato de correo inválido",
|
||||||
"save_failed": "Error al guardar el contacto",
|
"save_failed": "Error al guardar el contacto",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "بازنشانی",
|
"demo_reset": "بازنشانی",
|
||||||
"demo_tour": "تور",
|
"demo_tour": "تور",
|
||||||
"tags": "برچسبها",
|
"tags": "برچسبها",
|
||||||
|
"show_all_tags": "نمایش همه ({count})",
|
||||||
|
"show_fewer_tags": "نمایش کمتر",
|
||||||
"folders": "پوشهها",
|
"folders": "پوشهها",
|
||||||
"shared": "اشتراکی",
|
"shared": "اشتراکی",
|
||||||
"mail": "ایمیل",
|
"mail": "ایمیل",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "چاپ",
|
"print": "چاپ",
|
||||||
"view_source": "مشاهده منبع",
|
"view_source": "مشاهده منبع",
|
||||||
"export_email": "خروجی .eml",
|
"export_email": "خروجی .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "وارد کردن .eml یا .zip",
|
"import_email": "وارد کردن .eml یا .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": "تنظیم برچسب",
|
||||||
"tag": "برچسب",
|
"tag": "برچسب",
|
||||||
"more_actions": "عملیات بیشتر",
|
"more_actions": "عملیات بیشتر",
|
||||||
"previous": "قبلی",
|
"previous": "قبلی",
|
||||||
"next": "بعدی",
|
"next": "بعدی",
|
||||||
"move_to": "انتقال به...",
|
"move_to": "انتقال به...",
|
||||||
"remove_color": "حذف برچسب",
|
"remove_tag": "حذف برچسب",
|
||||||
|
"tag_filter_placeholder": "فیلتر برچسبها",
|
||||||
|
"tag_no_matches": "برچسب مطابقی یافت نشد",
|
||||||
"more_count": "+{count} بیشتر",
|
"more_count": "+{count} بیشتر",
|
||||||
"characters_count": "{count} کاراکتر",
|
"characters_count": "{count} کاراکتر",
|
||||||
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
|
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "شناسه پیام",
|
"message_id": "شناسه پیام",
|
||||||
"list_info": "اطلاعات لیست"
|
"list_info": "اطلاعات لیست"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "برچسب رنگی",
|
|
||||||
"red": "قرمز",
|
|
||||||
"orange": "نارنجی",
|
|
||||||
"yellow": "زرد",
|
|
||||||
"green": "سبز",
|
|
||||||
"blue": "آبی",
|
|
||||||
"purple": "بنفش",
|
|
||||||
"pink": "صورتی",
|
|
||||||
"none": "هیچکدام"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "پاسخ (r)",
|
"reply": "پاسخ (r)",
|
||||||
"reply_all": "پاسخ به همه (a)",
|
"reply_all": "پاسخ به همه (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "نام نمایشی",
|
"recipient_name_placeholder": "نام نمایشی",
|
||||||
"autocomplete_search_server": "جستجو در سرور",
|
"autocomplete_search_server": "جستجو در سرور",
|
||||||
"autocomplete_searching": "در حال جستجو...",
|
"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": "ارسال شد - اما پاکسازی پس از ارسال ناموفق بود، ممکن است پیشنویس قدیمی باقی بماند."
|
"send_filing_warning": "ارسال شد - اما پاکسازی پس از ارسال ناموفق بود، ممکن است پیشنویس قدیمی باقی بماند."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "برچسبهای ایمیل",
|
"title": "برچسبهای ایمیل",
|
||||||
"description": "برچسبها را برای سازماندهی ایمیلها تعریف کنید",
|
"description": "برچسبها را برای سازماندهی ایمیلها تعریف کنید",
|
||||||
"add_keyword": "افزودن برچسب",
|
"add_keyword": "افزودن برچسب",
|
||||||
"reset_defaults": "بازنشانی به پیشفرض",
|
|
||||||
"label_field": "نام نمایشی",
|
"label_field": "نام نمایشی",
|
||||||
"label_placeholder": "مثال: کاری، شخصی، فوری",
|
"label_placeholder": "مثال: کاری، شخصی، فوری",
|
||||||
"id_field": "شناسه برچسب",
|
"id_field": "شناسه برچسب",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "افزودن",
|
"add": "افزودن",
|
||||||
"cancel": "انصراف",
|
"cancel": "انصراف",
|
||||||
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "تست صدای اعلان",
|
"test_sound": "تست صدای اعلان",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"mark_as_spam": "گزارش هرزنامه",
|
"mark_as_spam": "گزارش هرزنامه",
|
||||||
"not_spam": "هرزنامه نیست",
|
"not_spam": "هرزنامه نیست",
|
||||||
"color_tag": "برچسب",
|
"tag": "برچسب",
|
||||||
"remove_color": "حذف برچسب",
|
|
||||||
"items_selected": "{count} ایمیل انتخاب شده",
|
"items_selected": "{count} ایمیل انتخاب شده",
|
||||||
"edit_draft": "ویرایش پیشنویس",
|
"edit_draft": "ویرایش پیشنویس",
|
||||||
"cancel_scheduled_send": "لغو ارسال",
|
"cancel_scheduled_send": "لغو ارسال",
|
||||||
@@ -2337,6 +2376,9 @@
|
|||||||
"section_address_book": "دفترچه",
|
"section_address_book": "دفترچه",
|
||||||
"select_address_book": "انتخاب دفترچه...",
|
"select_address_book": "انتخاب دفترچه...",
|
||||||
"section_identity": "نام و هویت",
|
"section_identity": "نام و هویت",
|
||||||
|
"contact_type": "نوع مخاطب",
|
||||||
|
"type_person": "شخص",
|
||||||
|
"type_organization": "سازمان",
|
||||||
"section_work": "کار و سازمان",
|
"section_work": "کار و سازمان",
|
||||||
"prefix": "پیشوند",
|
"prefix": "پیشوند",
|
||||||
"prefix_placeholder": "دکتر، مهندس",
|
"prefix_placeholder": "دکتر، مهندس",
|
||||||
@@ -2421,7 +2463,7 @@
|
|||||||
"cancel": "انصراف",
|
"cancel": "انصراف",
|
||||||
"creating": "در حال ایجاد...",
|
"creating": "در حال ایجاد...",
|
||||||
"updating": "در حال بهروزرسانی...",
|
"updating": "در حال بهروزرسانی...",
|
||||||
"name_required": "حداقل نام یا نام خانوادگی الزامی است",
|
"name_required": "نام، نام خانوادگی یا سازمان را وارد کنید",
|
||||||
"email_invalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید",
|
"email_invalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید",
|
||||||
"email_error_inline": "فرمت ایمیل نامعتبر است",
|
"email_error_inline": "فرمت ایمیل نامعتبر است",
|
||||||
"save_failed": "ذخیره مخاطب ناموفق بود",
|
"save_failed": "ذخیره مخاطب ناموفق بود",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Réinitialiser",
|
"demo_reset": "Réinitialiser",
|
||||||
"demo_tour": "Visite",
|
"demo_tour": "Visite",
|
||||||
"tags": "Étiquettes",
|
"tags": "Étiquettes",
|
||||||
|
"show_all_tags": "Tout afficher ({count})",
|
||||||
|
"show_fewer_tags": "Afficher moins",
|
||||||
"folders": "Dossiers",
|
"folders": "Dossiers",
|
||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimer",
|
"print": "Imprimer",
|
||||||
"view_source": "Voir la source",
|
"view_source": "Voir la source",
|
||||||
"export_email": "Exporter en .eml",
|
"export_email": "Exporter en .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importer .eml ou .zip",
|
"import_email": "Importer .eml ou .zip",
|
||||||
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
||||||
"email_source": "Source de l'email",
|
"email_source": "Source de l'email",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Voir le contact",
|
"view_contact": "Voir le contact",
|
||||||
"message_details": "Détails du message",
|
"message_details": "Détails du message",
|
||||||
"more_reply_options": "Plus d'options de réponse",
|
"more_reply_options": "Plus d'options de réponse",
|
||||||
"set_color": "Définir l'étiquette",
|
"set_tag": "Définir l'étiquette",
|
||||||
"tag": "Étiquette",
|
"tag": "Étiquette",
|
||||||
"more_actions": "Plus d'actions",
|
"more_actions": "Plus d'actions",
|
||||||
"move_to": "Déplacer vers...",
|
"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",
|
"more_count": "+{count} de plus",
|
||||||
"characters_count": "{count} caractères",
|
"characters_count": "{count} caractères",
|
||||||
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID du message",
|
"message_id": "ID du message",
|
||||||
"list_info": "Information de liste"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Répondre",
|
"reply": "Répondre",
|
||||||
"reply_all": "Répondre à tous (a)",
|
"reply_all": "Répondre à tous (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Nom d'affichage",
|
"recipient_name_placeholder": "Nom d'affichage",
|
||||||
"autocomplete_search_server": "Rechercher sur le serveur",
|
"autocomplete_search_server": "Rechercher sur le serveur",
|
||||||
"autocomplete_searching": "Recherche en cours...",
|
"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."
|
"send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Étiquettes de messagerie",
|
"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.",
|
"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",
|
"add_keyword": "Ajouter une étiquette",
|
||||||
"reset_defaults": "Réinitialiser par défaut",
|
|
||||||
"label_field": "Nom d'affichage",
|
"label_field": "Nom d'affichage",
|
||||||
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
||||||
"id_field": "ID d'étiquette",
|
"id_field": "ID d'étiquette",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Tester le son de notification",
|
"test_sound": "Tester le son de notification",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"mark_as_spam": "Signaler comme spam",
|
"mark_as_spam": "Signaler comme spam",
|
||||||
"not_spam": "Pas un spam",
|
"not_spam": "Pas un spam",
|
||||||
"color_tag": "Étiquette",
|
"tag": "Étiquette",
|
||||||
"remove_color": "Supprimer l'étiquette",
|
|
||||||
"items_selected": "{count} emails sélectionnés",
|
"items_selected": "{count} emails sélectionnés",
|
||||||
"edit_draft": "Modifier le brouillon",
|
"edit_draft": "Modifier le brouillon",
|
||||||
"cancel_scheduled_send": "Annuler l’envoi",
|
"cancel_scheduled_send": "Annuler l’envoi",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Répertoire",
|
"section_address_book": "Répertoire",
|
||||||
"select_address_book": "Sélectionner un répertoire...",
|
"select_address_book": "Sélectionner un répertoire...",
|
||||||
"section_identity": "Nom et identité",
|
"section_identity": "Nom et identité",
|
||||||
|
"contact_type": "Type de contact",
|
||||||
|
"type_person": "Personne",
|
||||||
|
"type_organization": "Organisation",
|
||||||
"section_work": "Travail et organisation",
|
"section_work": "Travail et organisation",
|
||||||
"prefix": "Préfixe",
|
"prefix": "Préfixe",
|
||||||
"prefix_placeholder": "Dr., M., Mme",
|
"prefix_placeholder": "Dr., M., Mme",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"creating": "Création...",
|
"creating": "Création...",
|
||||||
"updating": "Mise à jour...",
|
"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_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||||
"email_error_inline": "Format d'e-mail invalide",
|
"email_error_inline": "Format d'e-mail invalide",
|
||||||
"save_failed": "Échec de l'enregistrement du contact",
|
"save_failed": "Échec de l'enregistrement du contact",
|
||||||
|
|||||||
+60
-18
@@ -122,6 +122,8 @@
|
|||||||
"demo_reset": "אִתחוּל",
|
"demo_reset": "אִתחוּל",
|
||||||
"demo_tour": "סִיוּר",
|
"demo_tour": "סִיוּר",
|
||||||
"tags": "תגים",
|
"tags": "תגים",
|
||||||
|
"show_all_tags": "הצג הכל ({count})",
|
||||||
|
"show_fewer_tags": "הצג פחות",
|
||||||
"folders": "תיקיות",
|
"folders": "תיקיות",
|
||||||
"mail": "דוֹאַר",
|
"mail": "דוֹאַר",
|
||||||
"nav_label": "ניווט",
|
"nav_label": "ניווט",
|
||||||
@@ -245,6 +247,7 @@
|
|||||||
"print": "הדפס",
|
"print": "הדפס",
|
||||||
"view_source": "צפה במקור",
|
"view_source": "צפה במקור",
|
||||||
"export_email": "ייצא כ-.eml",
|
"export_email": "ייצא כ-.eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "ייבוא .eml",
|
"import_email": "ייבוא .eml",
|
||||||
"keyboard_shortcuts": "קיצורי מקשים (?)",
|
"keyboard_shortcuts": "קיצורי מקשים (?)",
|
||||||
"email_source": "מקור דוא\"ל",
|
"email_source": "מקור דוא\"ל",
|
||||||
@@ -271,13 +274,15 @@
|
|||||||
"view_contact": "הצג איש קשר",
|
"view_contact": "הצג איש קשר",
|
||||||
"message_details": "פרטי הודעה",
|
"message_details": "פרטי הודעה",
|
||||||
"more_reply_options": "אפשרויות תשובה נוספות",
|
"more_reply_options": "אפשרויות תשובה נוספות",
|
||||||
"set_color": "הגדר תג",
|
"set_tag": "הגדר תג",
|
||||||
"tag": "תג",
|
"tag": "תג",
|
||||||
"more_actions": "עוד פעולות",
|
"more_actions": "עוד פעולות",
|
||||||
"previous": "הקודם",
|
"previous": "הקודם",
|
||||||
"next": "הבא",
|
"next": "הבא",
|
||||||
"move_to": "העבר ל...",
|
"move_to": "העבר ל...",
|
||||||
"remove_color": "הסר תג",
|
"remove_tag": "הסר תג",
|
||||||
|
"tag_filter_placeholder": "סינון תגים",
|
||||||
|
"tag_no_matches": "אין תגים תואמים",
|
||||||
"more_count": "+{count}נוספים",
|
"more_count": "+{count}נוספים",
|
||||||
"characters_count": "{count} תווים",
|
"characters_count": "{count} תווים",
|
||||||
"quick_reply_placeholder": "תשובה מהירה",
|
"quick_reply_placeholder": "תשובה מהירה",
|
||||||
@@ -346,17 +351,6 @@
|
|||||||
"message_id": "מזהה הודעה",
|
"message_id": "מזהה הודעה",
|
||||||
"list_info": "רשימת מידע"
|
"list_info": "רשימת מידע"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "תג צבע",
|
|
||||||
"red": "אדום",
|
|
||||||
"orange": "כתום",
|
|
||||||
"yellow": "צהוב",
|
|
||||||
"green": "ירוק",
|
|
||||||
"blue": "כחול",
|
|
||||||
"purple": "סגול",
|
|
||||||
"pink": "ורוד",
|
|
||||||
"none": "אין"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "תשובה (ר)",
|
"reply": "תשובה (ר)",
|
||||||
"reply_all": "השב לכולם (א)",
|
"reply_all": "השב לכולם (א)",
|
||||||
@@ -651,6 +645,38 @@
|
|||||||
"recipient_name_placeholder": "שם תצוגה",
|
"recipient_name_placeholder": "שם תצוגה",
|
||||||
"autocomplete_search_server": "חיפוש בשרת",
|
"autocomplete_search_server": "חיפוש בשרת",
|
||||||
"autocomplete_searching": "מחפש...",
|
"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": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
|
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -948,7 +974,6 @@
|
|||||||
"title": "מילות מפתח בדוא\"ל",
|
"title": "מילות מפתח בדוא\"ל",
|
||||||
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
|
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
|
||||||
"add_keyword": "הוסף מילת מפתח",
|
"add_keyword": "הוסף מילת מפתח",
|
||||||
"reset_defaults": "אפס לברירות מחדל",
|
|
||||||
"label_field": "שם תצוגה",
|
"label_field": "שם תצוגה",
|
||||||
"label_placeholder": "למשל עבודה, אישי, דחוף",
|
"label_placeholder": "למשל עבודה, אישי, דחוף",
|
||||||
"id_field": "מזהה מילת מפתח",
|
"id_field": "מזהה מילת מפתח",
|
||||||
@@ -961,7 +986,22 @@
|
|||||||
"add": "לְהוֹסִיף",
|
"add": "לְהוֹסִיף",
|
||||||
"cancel": "לְבַטֵל",
|
"cancel": "לְבַטֵל",
|
||||||
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "צליל הודעת בדיקה",
|
"test_sound": "צליל הודעת בדיקה",
|
||||||
@@ -1967,8 +2007,7 @@
|
|||||||
"delete": "לִמְחוֹק",
|
"delete": "לִמְחוֹק",
|
||||||
"mark_as_spam": "דווח על ספאם",
|
"mark_as_spam": "דווח על ספאם",
|
||||||
"not_spam": "לא ספאם",
|
"not_spam": "לא ספאם",
|
||||||
"color_tag": "תווית",
|
"tag": "תווית",
|
||||||
"remove_color": "הסר תווית",
|
|
||||||
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
|
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
|
||||||
"edit_draft": "ערוך טיוטה",
|
"edit_draft": "ערוך טיוטה",
|
||||||
"cancel_scheduled_send": "ביטול שליחה",
|
"cancel_scheduled_send": "ביטול שליחה",
|
||||||
@@ -2250,6 +2289,9 @@
|
|||||||
"section_address_book": "ספרייה",
|
"section_address_book": "ספרייה",
|
||||||
"select_address_book": "בחר ספרייה...",
|
"select_address_book": "בחר ספרייה...",
|
||||||
"section_identity": "שם וזהות",
|
"section_identity": "שם וזהות",
|
||||||
|
"contact_type": "סוג איש קשר",
|
||||||
|
"type_person": "אדם",
|
||||||
|
"type_organization": "ארגון",
|
||||||
"section_work": "עבודה וארגון",
|
"section_work": "עבודה וארגון",
|
||||||
"prefix": "קידומת",
|
"prefix": "קידומת",
|
||||||
"prefix_placeholder": "ד\"ר, מר, גברת.",
|
"prefix_placeholder": "ד\"ר, מר, גברת.",
|
||||||
@@ -2334,7 +2376,7 @@
|
|||||||
"cancel": "לְבַטֵל",
|
"cancel": "לְבַטֵל",
|
||||||
"creating": "יוצר...",
|
"creating": "יוצר...",
|
||||||
"updating": "מעדכן...",
|
"updating": "מעדכן...",
|
||||||
"name_required": "נדרש לפחות שם פרטי או שם משפחה",
|
"name_required": "יש להזין שם פרטי, שם משפחה או ארגון",
|
||||||
"email_invalid": "נא להזין כתובת אימייל חוקית",
|
"email_invalid": "נא להזין כתובת אימייל חוקית",
|
||||||
"email_error_inline": "פורמט אימייל לא חוקי",
|
"email_error_inline": "פורמט אימייל לא חוקי",
|
||||||
"save_failed": "שמירת איש הקשר נכשלה",
|
"save_failed": "שמירת איש הקשר נכשלה",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Visszaállítás",
|
"demo_reset": "Visszaállítás",
|
||||||
"demo_tour": "Bemutató",
|
"demo_tour": "Bemutató",
|
||||||
"tags": "Címkék",
|
"tags": "Címkék",
|
||||||
|
"show_all_tags": "Összes megjelenítése ({count})",
|
||||||
|
"show_fewer_tags": "Kevesebb megjelenítése",
|
||||||
"folders": "Mappák",
|
"folders": "Mappák",
|
||||||
"shared": "Megosztott",
|
"shared": "Megosztott",
|
||||||
"mail": "Levelek",
|
"mail": "Levelek",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Nyomtatás",
|
"print": "Nyomtatás",
|
||||||
"view_source": "Forrás megtekintése",
|
"view_source": "Forrás megtekintése",
|
||||||
"export_email": "Exportálás .eml-ként",
|
"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",
|
"import_email": "Importálás .eml vagy .zip fájlból",
|
||||||
"keyboard_shortcuts": "Billentyűparancsok (?)",
|
"keyboard_shortcuts": "Billentyűparancsok (?)",
|
||||||
"email_source": "E-mail forrás",
|
"email_source": "E-mail forrás",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Névjegy megtekintése",
|
"view_contact": "Névjegy megtekintése",
|
||||||
"message_details": "Üzenet részletei",
|
"message_details": "Üzenet részletei",
|
||||||
"more_reply_options": "További válasz opciók",
|
"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",
|
"tag": "Címke",
|
||||||
"more_actions": "További műveletek",
|
"more_actions": "További műveletek",
|
||||||
"previous": "Előző",
|
"previous": "Előző",
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
"move_to": "Áthelyezés ide...",
|
"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",
|
"more_count": "+{count} további",
|
||||||
"characters_count": "{count} karakter",
|
"characters_count": "{count} karakter",
|
||||||
"quick_reply_placeholder": "Gyors válasz írása...",
|
"quick_reply_placeholder": "Gyors válasz írása...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "Üzenet azonosító",
|
"message_id": "Üzenet azonosító",
|
||||||
"list_info": "Lista információk"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Válasz (r)",
|
"reply": "Válasz (r)",
|
||||||
"reply_all": "Válasz mindenkinek (a)",
|
"reply_all": "Válasz mindenkinek (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Megjelenített név",
|
"recipient_name_placeholder": "Megjelenített név",
|
||||||
"autocomplete_search_server": "Keresés a kiszolgálón",
|
"autocomplete_search_server": "Keresés a kiszolgálón",
|
||||||
"autocomplete_searching": "Keresés...",
|
"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."
|
"send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "E-mail címkék",
|
"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.",
|
"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",
|
"add_keyword": "Címke hozzáadása",
|
||||||
"reset_defaults": "Alapértelmezettek visszaállítása",
|
|
||||||
"label_field": "Megjelenített név",
|
"label_field": "Megjelenített név",
|
||||||
"label_placeholder": "pl. Munka, Személyes, Sürgős",
|
"label_placeholder": "pl. Munka, Személyes, Sürgős",
|
||||||
"id_field": "Címke azonosító",
|
"id_field": "Címke azonosító",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "Hozzáadás",
|
"add": "Hozzáadás",
|
||||||
"cancel": "Mégse",
|
"cancel": "Mégse",
|
||||||
"migrating": "Címke frissítése a meglévő e-maileken...",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Értesítési hang tesztelése",
|
"test_sound": "Értesítési hang tesztelése",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"mark_as_spam": "Spam jelentése",
|
"mark_as_spam": "Spam jelentése",
|
||||||
"not_spam": "Nem spam",
|
"not_spam": "Nem spam",
|
||||||
"color_tag": "Címke",
|
"tag": "Címke",
|
||||||
"remove_color": "Címke eltávolítása",
|
|
||||||
"items_selected": "{count} e-mail kijelölve",
|
"items_selected": "{count} e-mail kijelölve",
|
||||||
"edit_draft": "Piszkozat szerkesztése",
|
"edit_draft": "Piszkozat szerkesztése",
|
||||||
"cancel_scheduled_send": "Küldés megszakítása",
|
"cancel_scheduled_send": "Küldés megszakítása",
|
||||||
@@ -2337,6 +2376,9 @@
|
|||||||
"section_address_book": "Címtár",
|
"section_address_book": "Címtár",
|
||||||
"select_address_book": "Címtár kiválasztása...",
|
"select_address_book": "Címtár kiválasztása...",
|
||||||
"section_identity": "Név és azonosság",
|
"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",
|
"section_work": "Munka és szervezet",
|
||||||
"prefix": "Előtag",
|
"prefix": "Előtag",
|
||||||
"prefix_placeholder": "Dr., Úr., Mrs.",
|
"prefix_placeholder": "Dr., Úr., Mrs.",
|
||||||
@@ -2421,7 +2463,7 @@
|
|||||||
"cancel": "Mégse",
|
"cancel": "Mégse",
|
||||||
"creating": "Létrehozás...",
|
"creating": "Létrehozás...",
|
||||||
"updating": "Frissíté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_invalid": "Kérjük, adj meg egy érvényes e-mail címet",
|
||||||
"email_error_inline": "Érvénytelen e-mail formátum",
|
"email_error_inline": "Érvénytelen e-mail formátum",
|
||||||
"save_failed": "Nem sikerült menteni a névjegyet",
|
"save_failed": "Nem sikerült menteni a névjegyet",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Reimposta",
|
"demo_reset": "Reimposta",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etichette",
|
"tags": "Etichette",
|
||||||
|
"show_all_tags": "Mostra tutto ({count})",
|
||||||
|
"show_fewer_tags": "Mostra meno",
|
||||||
"folders": "Cartelle",
|
"folders": "Cartelle",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Navigazione",
|
"nav_label": "Navigazione",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Stampa",
|
"print": "Stampa",
|
||||||
"view_source": "Visualizza sorgente",
|
"view_source": "Visualizza sorgente",
|
||||||
"export_email": "Esporta come .eml",
|
"export_email": "Esporta come .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importa .eml o .zip",
|
"import_email": "Importa .eml o .zip",
|
||||||
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
||||||
"email_source": "Sorgente del messaggio",
|
"email_source": "Sorgente del messaggio",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Visualizza contatto",
|
"view_contact": "Visualizza contatto",
|
||||||
"message_details": "Dettagli del messaggio",
|
"message_details": "Dettagli del messaggio",
|
||||||
"more_reply_options": "Più opzioni di risposta",
|
"more_reply_options": "Più opzioni di risposta",
|
||||||
"set_color": "Imposta etichetta",
|
"set_tag": "Imposta etichetta",
|
||||||
"tag": "Etichetta",
|
"tag": "Etichetta",
|
||||||
"more_actions": "Altre azioni",
|
"more_actions": "Altre azioni",
|
||||||
"move_to": "Sposta in...",
|
"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",
|
"more_count": "+{count} altri",
|
||||||
"characters_count": "{count} caratteri",
|
"characters_count": "{count} caratteri",
|
||||||
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID messaggio",
|
"message_id": "ID messaggio",
|
||||||
"list_info": "Informazioni lista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Rispondi",
|
"reply": "Rispondi",
|
||||||
"reply_all": "Rispondi a tutti (a)",
|
"reply_all": "Rispondi a tutti (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Nome visualizzato",
|
"recipient_name_placeholder": "Nome visualizzato",
|
||||||
"autocomplete_search_server": "Cerca nel server",
|
"autocomplete_search_server": "Cerca nel server",
|
||||||
"autocomplete_searching": "Ricerca in corso...",
|
"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."
|
"send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Etichette e-mail",
|
"title": "Etichette e-mail",
|
||||||
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
|
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
|
||||||
"add_keyword": "Aggiungi etichetta",
|
"add_keyword": "Aggiungi etichetta",
|
||||||
"reset_defaults": "Ripristina predefiniti",
|
|
||||||
"label_field": "Nome visualizzato",
|
"label_field": "Nome visualizzato",
|
||||||
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
||||||
"id_field": "ID etichetta",
|
"id_field": "ID etichetta",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Aggiungi",
|
"add": "Aggiungi",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Testa il suono di notifica",
|
"test_sound": "Testa il suono di notifica",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"mark_as_spam": "Segnala come spam",
|
"mark_as_spam": "Segnala come spam",
|
||||||
"not_spam": "Non spam",
|
"not_spam": "Non spam",
|
||||||
"color_tag": "Etichetta",
|
"tag": "Etichetta",
|
||||||
"remove_color": "Rimuovi etichetta",
|
|
||||||
"items_selected": "{count} messaggi selezionati",
|
"items_selected": "{count} messaggi selezionati",
|
||||||
"edit_draft": "Modifica bozza",
|
"edit_draft": "Modifica bozza",
|
||||||
"cancel_scheduled_send": "Annulla invio",
|
"cancel_scheduled_send": "Annulla invio",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Rubrica",
|
"section_address_book": "Rubrica",
|
||||||
"select_address_book": "Seleziona una rubrica...",
|
"select_address_book": "Seleziona una rubrica...",
|
||||||
"section_identity": "Nome e identità",
|
"section_identity": "Nome e identità",
|
||||||
|
"contact_type": "Tipo di contatto",
|
||||||
|
"type_person": "Persona",
|
||||||
|
"type_organization": "Organizzazione",
|
||||||
"section_work": "Lavoro e organizzazione",
|
"section_work": "Lavoro e organizzazione",
|
||||||
"prefix": "Prefisso",
|
"prefix": "Prefisso",
|
||||||
"prefix_placeholder": "Dott., Sig., Sig.ra",
|
"prefix_placeholder": "Dott., Sig., Sig.ra",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
"creating": "Creazione...",
|
"creating": "Creazione...",
|
||||||
"updating": "Aggiornamento...",
|
"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_invalid": "Inserisci un indirizzo email valido",
|
||||||
"email_error_inline": "Formato email non valido",
|
"email_error_inline": "Formato email non valido",
|
||||||
"save_failed": "Impossibile salvare il contatto",
|
"save_failed": "Impossibile salvare il contatto",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "リセット",
|
"demo_reset": "リセット",
|
||||||
"demo_tour": "ツアー",
|
"demo_tour": "ツアー",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
|
"show_all_tags": "すべて表示({count})",
|
||||||
|
"show_fewer_tags": "表示を減らす",
|
||||||
"folders": "フォルダ",
|
"folders": "フォルダ",
|
||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション",
|
"nav_label": "ナビゲーション",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "印刷",
|
"print": "印刷",
|
||||||
"view_source": "ソースを表示",
|
"view_source": "ソースを表示",
|
||||||
"export_email": ".emlとしてエクスポート",
|
"export_email": ".emlとしてエクスポート",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml または .zip をインポート",
|
"import_email": ".eml または .zip をインポート",
|
||||||
"keyboard_shortcuts": "キーボードショートカット (?)",
|
"keyboard_shortcuts": "キーボードショートカット (?)",
|
||||||
"email_source": "メールソース",
|
"email_source": "メールソース",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "連絡先を表示",
|
"view_contact": "連絡先を表示",
|
||||||
"message_details": "メッセージの詳細",
|
"message_details": "メッセージの詳細",
|
||||||
"more_reply_options": "その他の返信オプション",
|
"more_reply_options": "その他の返信オプション",
|
||||||
"set_color": "ラベルを設定",
|
"set_tag": "ラベルを設定",
|
||||||
"tag": "ラベル",
|
"tag": "ラベル",
|
||||||
"more_actions": "その他の操作",
|
"more_actions": "その他の操作",
|
||||||
"move_to": "移動...",
|
"move_to": "移動...",
|
||||||
"remove_color": "ラベルを削除",
|
"remove_tag": "ラベルを削除",
|
||||||
|
"tag_filter_placeholder": "ラベルを絞り込む",
|
||||||
|
"tag_no_matches": "一致するラベルがありません",
|
||||||
"more_count": "他{count}件",
|
"more_count": "他{count}件",
|
||||||
"characters_count": "{count}文字",
|
"characters_count": "{count}文字",
|
||||||
"quick_reply_placeholder": "クイック返信を入力...",
|
"quick_reply_placeholder": "クイック返信を入力...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "メッセージID",
|
"message_id": "メッセージID",
|
||||||
"list_info": "リスト情報"
|
"list_info": "リスト情報"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "カラータグ",
|
|
||||||
"red": "赤",
|
|
||||||
"orange": "オレンジ",
|
|
||||||
"yellow": "黄色",
|
|
||||||
"green": "緑",
|
|
||||||
"blue": "青",
|
|
||||||
"purple": "紫",
|
|
||||||
"pink": "ピンク",
|
|
||||||
"none": "なし"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "返信",
|
"reply": "返信",
|
||||||
"reply_all": "全員に返信 (a)",
|
"reply_all": "全員に返信 (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "表示名",
|
"recipient_name_placeholder": "表示名",
|
||||||
"autocomplete_search_server": "サーバーを検索",
|
"autocomplete_search_server": "サーバーを検索",
|
||||||
"autocomplete_searching": "検索中...",
|
"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": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
|
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "メールラベル",
|
"title": "メールラベル",
|
||||||
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
|
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
|
||||||
"add_keyword": "ラベルを追加",
|
"add_keyword": "ラベルを追加",
|
||||||
"reset_defaults": "デフォルトに戻す",
|
|
||||||
"label_field": "表示名",
|
"label_field": "表示名",
|
||||||
"label_placeholder": "例:仕事、個人、緊急",
|
"label_placeholder": "例:仕事、個人、緊急",
|
||||||
"id_field": "ラベルID",
|
"id_field": "ラベルID",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "追加",
|
"add": "追加",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"migrating": "既存のメールのラベルを更新中…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "通知音をテスト",
|
"test_sound": "通知音をテスト",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"mark_as_spam": "迷惑メールを報告",
|
"mark_as_spam": "迷惑メールを報告",
|
||||||
"not_spam": "迷惑メールでない",
|
"not_spam": "迷惑メールでない",
|
||||||
"color_tag": "ラベル",
|
"tag": "ラベル",
|
||||||
"remove_color": "ラベルを削除",
|
|
||||||
"items_selected": "{count}件のメールを選択",
|
"items_selected": "{count}件のメールを選択",
|
||||||
"edit_draft": "下書きを編集",
|
"edit_draft": "下書きを編集",
|
||||||
"cancel_scheduled_send": "送信をキャンセル",
|
"cancel_scheduled_send": "送信をキャンセル",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "ディレクトリ",
|
"section_address_book": "ディレクトリ",
|
||||||
"select_address_book": "ディレクトリを選択...",
|
"select_address_book": "ディレクトリを選択...",
|
||||||
"section_identity": "名前と識別情報",
|
"section_identity": "名前と識別情報",
|
||||||
|
"contact_type": "連絡先の種類",
|
||||||
|
"type_person": "個人",
|
||||||
|
"type_organization": "組織",
|
||||||
"section_work": "職業と組織",
|
"section_work": "職業と組織",
|
||||||
"prefix": "敬称",
|
"prefix": "敬称",
|
||||||
"prefix_placeholder": "博士、氏",
|
"prefix_placeholder": "博士、氏",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"creating": "作成中...",
|
"creating": "作成中...",
|
||||||
"updating": "更新中...",
|
"updating": "更新中...",
|
||||||
"name_required": "名前は必須です",
|
"name_required": "名、姓、または組織を入力してください",
|
||||||
"email_invalid": "有効なメールアドレスを入力してください",
|
"email_invalid": "有効なメールアドレスを入力してください",
|
||||||
"email_error_inline": "メールアドレスの形式が正しくありません",
|
"email_error_inline": "メールアドレスの形式が正しくありません",
|
||||||
"save_failed": "連絡先の保存に失敗しました",
|
"save_failed": "連絡先の保存に失敗しました",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "초기화",
|
"demo_reset": "초기화",
|
||||||
"demo_tour": "둘러보기",
|
"demo_tour": "둘러보기",
|
||||||
"tags": "태그",
|
"tags": "태그",
|
||||||
|
"show_all_tags": "전체 보기({count})",
|
||||||
|
"show_fewer_tags": "간략히 보기",
|
||||||
"folders": "폴더",
|
"folders": "폴더",
|
||||||
"mail": "메일",
|
"mail": "메일",
|
||||||
"nav_label": "내비게이션",
|
"nav_label": "내비게이션",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "인쇄",
|
"print": "인쇄",
|
||||||
"view_source": "원본 보기",
|
"view_source": "원본 보기",
|
||||||
"export_email": ".eml 파일로 내보내기",
|
"export_email": ".eml 파일로 내보내기",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml 또는 .zip 가져오기",
|
"import_email": ".eml 또는 .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": "태그 설정",
|
||||||
"tag": "태그",
|
"tag": "태그",
|
||||||
"more_actions": "작업 더보기",
|
"more_actions": "작업 더보기",
|
||||||
"previous": "이전",
|
"previous": "이전",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"move_to": "이동...",
|
"move_to": "이동...",
|
||||||
"remove_color": "태그 제거",
|
"remove_tag": "태그 제거",
|
||||||
|
"tag_filter_placeholder": "태그 검색",
|
||||||
|
"tag_no_matches": "일치하는 태그 없음",
|
||||||
"more_count": "+{count}개 더보기",
|
"more_count": "+{count}개 더보기",
|
||||||
"characters_count": "{count}자",
|
"characters_count": "{count}자",
|
||||||
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
|
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "메시지 ID",
|
"message_id": "메시지 ID",
|
||||||
"list_info": "목록 정보"
|
"list_info": "목록 정보"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "색상 태그",
|
|
||||||
"red": "빨간색",
|
|
||||||
"orange": "주황색",
|
|
||||||
"yellow": "노란색",
|
|
||||||
"green": "초록색",
|
|
||||||
"blue": "파란색",
|
|
||||||
"purple": "보라색",
|
|
||||||
"pink": "분홍색",
|
|
||||||
"none": "없음"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "답장 (r)",
|
"reply": "답장 (r)",
|
||||||
"reply_all": "전체 답장 (a)",
|
"reply_all": "전체 답장 (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "표시 이름",
|
"recipient_name_placeholder": "표시 이름",
|
||||||
"autocomplete_search_server": "서버에서 검색",
|
"autocomplete_search_server": "서버에서 검색",
|
||||||
"autocomplete_searching": "검색 중...",
|
"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": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
|
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "이메일 태그",
|
"title": "이메일 태그",
|
||||||
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
|
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
|
||||||
"add_keyword": "태그 추가",
|
"add_keyword": "태그 추가",
|
||||||
"reset_defaults": "기본값으로 초기화",
|
|
||||||
"label_field": "표시 이름",
|
"label_field": "표시 이름",
|
||||||
"label_placeholder": "예: 업무, 개인, 긴급",
|
"label_placeholder": "예: 업무, 개인, 긴급",
|
||||||
"id_field": "태그 ID",
|
"id_field": "태그 ID",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "추가",
|
"add": "추가",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"migrating": "기존 이메일의 태그 업데이트 중…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "알림음 테스트",
|
"test_sound": "알림음 테스트",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"mark_as_spam": "스팸 신고",
|
"mark_as_spam": "스팸 신고",
|
||||||
"not_spam": "정상 메일",
|
"not_spam": "정상 메일",
|
||||||
"color_tag": "태그",
|
"tag": "태그",
|
||||||
"remove_color": "태그 제거",
|
|
||||||
"items_selected": "{count}개의 메일 선택됨",
|
"items_selected": "{count}개의 메일 선택됨",
|
||||||
"edit_draft": "임시보관 메일 수정",
|
"edit_draft": "임시보관 메일 수정",
|
||||||
"cancel_scheduled_send": "보내기 취소",
|
"cancel_scheduled_send": "보내기 취소",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "디렉터리",
|
"section_address_book": "디렉터리",
|
||||||
"select_address_book": "디렉터리 선택...",
|
"select_address_book": "디렉터리 선택...",
|
||||||
"section_identity": "이름 및 신원",
|
"section_identity": "이름 및 신원",
|
||||||
|
"contact_type": "연락처 유형",
|
||||||
|
"type_person": "개인",
|
||||||
|
"type_organization": "소속(회사)",
|
||||||
"section_work": "직장 및 소속",
|
"section_work": "직장 및 소속",
|
||||||
"prefix": "호칭",
|
"prefix": "호칭",
|
||||||
"prefix_placeholder": "예: Dr., Mr., Mrs.",
|
"prefix_placeholder": "예: Dr., Mr., Mrs.",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"creating": "만드는 중...",
|
"creating": "만드는 중...",
|
||||||
"updating": "업데이트 중...",
|
"updating": "업데이트 중...",
|
||||||
"name_required": "이름이나 성 중에 하나는 꼭 필요해요",
|
"name_required": "이름, 성 또는 조직을 입력하세요",
|
||||||
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
|
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
|
||||||
"email_error_inline": "이메일 형식이 잘못되었어요",
|
"email_error_inline": "이메일 형식이 잘못되었어요",
|
||||||
"save_failed": "연락처를 저장하지 못했어요",
|
"save_failed": "연락처를 저장하지 못했어요",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Atiestatīt",
|
"demo_reset": "Atiestatīt",
|
||||||
"demo_tour": "Ekskursija",
|
"demo_tour": "Ekskursija",
|
||||||
"tags": "Tagi",
|
"tags": "Tagi",
|
||||||
|
"show_all_tags": "Rādīt visus ({count})",
|
||||||
|
"show_fewer_tags": "Rādīt mazāk",
|
||||||
"folders": "Mapes",
|
"folders": "Mapes",
|
||||||
"mail": "Pasts",
|
"mail": "Pasts",
|
||||||
"nav_label": "Navigācija",
|
"nav_label": "Navigācija",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Drukāt",
|
"print": "Drukāt",
|
||||||
"view_source": "Skatīt avota kodu",
|
"view_source": "Skatīt avota kodu",
|
||||||
"export_email": "Eksportēt kā .eml",
|
"export_email": "Eksportēt kā .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importēt .eml vai .zip",
|
"import_email": "Importēt .eml vai .zip",
|
||||||
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
||||||
"email_source": "Vēstules avota kods",
|
"email_source": "Vēstules avota kods",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Skatīt kontaktu",
|
"view_contact": "Skatīt kontaktu",
|
||||||
"message_details": "Informācija par ziņojumu",
|
"message_details": "Informācija par ziņojumu",
|
||||||
"more_reply_options": "Papildu atbildēšanas iespējas",
|
"more_reply_options": "Papildu atbildēšanas iespējas",
|
||||||
"set_color": "Iestatīt tagu",
|
"set_tag": "Iestatīt tagu",
|
||||||
"tag": "Tags",
|
"tag": "Tags",
|
||||||
"more_actions": "Citas darbības",
|
"more_actions": "Citas darbības",
|
||||||
"previous": "Iepr.",
|
"previous": "Iepr.",
|
||||||
"next": "Nāk.",
|
"next": "Nāk.",
|
||||||
"move_to": "Pārvietot uz...",
|
"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}",
|
"more_count": "+vairāk {count}",
|
||||||
"characters_count": "{count} rakstzīmes",
|
"characters_count": "{count} rakstzīmes",
|
||||||
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
|
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "Ziņojuma ID",
|
"message_id": "Ziņojuma ID",
|
||||||
"list_info": "Informācija par adresātu sarakstu"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Atbildēt (r)",
|
"reply": "Atbildēt (r)",
|
||||||
"reply_all": "Atbildēt visiem (a)",
|
"reply_all": "Atbildēt visiem (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Parādāmais vārds",
|
"recipient_name_placeholder": "Parādāmais vārds",
|
||||||
"autocomplete_search_server": "Meklēt serverī",
|
"autocomplete_search_server": "Meklēt serverī",
|
||||||
"autocomplete_searching": "Meklē...",
|
"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."
|
"send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "E-pasta tagi",
|
"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ī.",
|
"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",
|
"add_keyword": "Pievienot tagu",
|
||||||
"reset_defaults": "Atiestatīt noklusējumu",
|
|
||||||
"label_field": "Redzamais nosaukums",
|
"label_field": "Redzamais nosaukums",
|
||||||
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
|
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
|
||||||
"id_field": "Taga identifikators",
|
"id_field": "Taga identifikators",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Pievienot",
|
"add": "Pievienot",
|
||||||
"cancel": "Atcelt",
|
"cancel": "Atcelt",
|
||||||
"migrating": "Taga atjaunināšana esošajos e-pastos…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Pārbaudīt paziņojuma skaņu",
|
"test_sound": "Pārbaudīt paziņojuma skaņu",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Dzēst",
|
"delete": "Dzēst",
|
||||||
"mark_as_spam": "Atzīmēt kā mēstuli",
|
"mark_as_spam": "Atzīmēt kā mēstuli",
|
||||||
"not_spam": "Nav mēstule",
|
"not_spam": "Nav mēstule",
|
||||||
"color_tag": "Tags",
|
"tag": "Tags",
|
||||||
"remove_color": "Noņemt tagu",
|
|
||||||
"items_selected": "{count} vēstules atlasītas",
|
"items_selected": "{count} vēstules atlasītas",
|
||||||
"edit_draft": "Rediģēt melnrakstu",
|
"edit_draft": "Rediģēt melnrakstu",
|
||||||
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||||
@@ -2332,6 +2371,9 @@
|
|||||||
"section_address_book": "Katalogs",
|
"section_address_book": "Katalogs",
|
||||||
"select_address_book": "Izvēlieties katalogu...",
|
"select_address_book": "Izvēlieties katalogu...",
|
||||||
"section_identity": "Vārds un identitāte",
|
"section_identity": "Vārds un identitāte",
|
||||||
|
"contact_type": "Kontakta veids",
|
||||||
|
"type_person": "Persona",
|
||||||
|
"type_organization": "Organizācija",
|
||||||
"section_work": "Darbs un organizācija",
|
"section_work": "Darbs un organizācija",
|
||||||
"prefix": "Prefikss",
|
"prefix": "Prefikss",
|
||||||
"prefix_placeholder": "Dr., kungs, kundze",
|
"prefix_placeholder": "Dr., kungs, kundze",
|
||||||
@@ -2416,7 +2458,7 @@
|
|||||||
"cancel": "Atcelt",
|
"cancel": "Atcelt",
|
||||||
"creating": "Izveido...",
|
"creating": "Izveido...",
|
||||||
"updating": "Atjaunina...",
|
"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_invalid": "Ievadiet derīgu e-pasta adresi",
|
||||||
"email_error_inline": "Nederīgs e-pasta formāts",
|
"email_error_inline": "Nederīgs e-pasta formāts",
|
||||||
"save_failed": "Neizdevās saglabāt kontaktu",
|
"save_failed": "Neizdevās saglabāt kontaktu",
|
||||||
|
|||||||
+61
-19
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetten",
|
"demo_reset": "Resetten",
|
||||||
"demo_tour": "Rondleiding",
|
"demo_tour": "Rondleiding",
|
||||||
"tags": "Labels",
|
"tags": "Labels",
|
||||||
|
"show_all_tags": "Alles tonen ({count})",
|
||||||
|
"show_fewer_tags": "Minder tonen",
|
||||||
"folders": "Mappen",
|
"folders": "Mappen",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Afdrukken",
|
"print": "Afdrukken",
|
||||||
"view_source": "Bron bekijken",
|
"view_source": "Bron bekijken",
|
||||||
"export_email": "Exporteren als .eml",
|
"export_email": "Exporteren als .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml of .zip importeren",
|
"import_email": ".eml of .zip importeren",
|
||||||
"keyboard_shortcuts": "Sneltoetsen (?)",
|
"keyboard_shortcuts": "Sneltoetsen (?)",
|
||||||
"email_source": "E-mailbron",
|
"email_source": "E-mailbron",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Contact bekijken",
|
"view_contact": "Contact bekijken",
|
||||||
"message_details": "Berichtdetails",
|
"message_details": "Berichtdetails",
|
||||||
"more_reply_options": "Meer antwoordopties",
|
"more_reply_options": "Meer antwoordopties",
|
||||||
"set_color": "Label instellen",
|
"set_tag": "Label instellen",
|
||||||
"tag": "Label",
|
"tag": "Label",
|
||||||
"more_actions": "Meer acties",
|
"more_actions": "Meer acties",
|
||||||
"move_to": "Verplaatsen naar...",
|
"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",
|
"more_count": "+{count} meer",
|
||||||
"characters_count": "{count} tekens",
|
"characters_count": "{count} tekens",
|
||||||
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "Bericht-ID",
|
"message_id": "Bericht-ID",
|
||||||
"list_info": "Lijstinformatie"
|
"list_info": "Lijstinformatie"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "Kleurtag",
|
|
||||||
"red": "Rood",
|
|
||||||
"orange": "Oranje",
|
|
||||||
"yellow": "Geel",
|
|
||||||
"green": "Groen",
|
|
||||||
"blue": "Blauw",
|
|
||||||
"purple": "Paars",
|
|
||||||
"pink": "Roze",
|
|
||||||
"none": "Geen"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "Beantwoorden",
|
"reply": "Beantwoorden",
|
||||||
"reply_all": "Allen beantwoorden (a)",
|
"reply_all": "Allen beantwoorden (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Weergavenaam",
|
"recipient_name_placeholder": "Weergavenaam",
|
||||||
"autocomplete_search_server": "Op de server zoeken",
|
"autocomplete_search_server": "Op de server zoeken",
|
||||||
"autocomplete_searching": "Bezig met 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."
|
"send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -981,9 +1007,8 @@
|
|||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
"title": "E-maillabels",
|
"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",
|
"add_keyword": "Label toevoegen",
|
||||||
"reset_defaults": "Standaardwaarden herstellen",
|
|
||||||
"label_field": "Weergavenaam",
|
"label_field": "Weergavenaam",
|
||||||
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
||||||
"id_field": "Label-ID",
|
"id_field": "Label-ID",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Toevoegen",
|
"add": "Toevoegen",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"migrating": "Label bijwerken op bestaande e-mails…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Meldingsgeluid testen",
|
"test_sound": "Meldingsgeluid testen",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"mark_as_spam": "Spam melden",
|
"mark_as_spam": "Spam melden",
|
||||||
"not_spam": "Geen spam",
|
"not_spam": "Geen spam",
|
||||||
"color_tag": "Label",
|
"tag": "Label",
|
||||||
"remove_color": "Label verwijderen",
|
|
||||||
"items_selected": "{count} e-mails geselecteerd",
|
"items_selected": "{count} e-mails geselecteerd",
|
||||||
"edit_draft": "Concept bewerken",
|
"edit_draft": "Concept bewerken",
|
||||||
"cancel_scheduled_send": "Verzenden annuleren",
|
"cancel_scheduled_send": "Verzenden annuleren",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Adresboek",
|
"section_address_book": "Adresboek",
|
||||||
"select_address_book": "Selecteer een adresboek...",
|
"select_address_book": "Selecteer een adresboek...",
|
||||||
"section_identity": "Naam en identiteit",
|
"section_identity": "Naam en identiteit",
|
||||||
|
"contact_type": "Contacttype",
|
||||||
|
"type_person": "Persoon",
|
||||||
|
"type_organization": "Organisatie",
|
||||||
"section_work": "Werk en organisatie",
|
"section_work": "Werk en organisatie",
|
||||||
"prefix": "Voorvoegsel",
|
"prefix": "Voorvoegsel",
|
||||||
"prefix_placeholder": "Dr., Dhr., Mevr.",
|
"prefix_placeholder": "Dr., Dhr., Mevr.",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"creating": "Aanmaken...",
|
"creating": "Aanmaken...",
|
||||||
"updating": "Bijwerken...",
|
"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_invalid": "Voer een geldig e-mailadres in",
|
||||||
"email_error_inline": "Ongeldig e-mailformaat",
|
"email_error_inline": "Ongeldig e-mailformaat",
|
||||||
"save_failed": "Kon contact niet opslaan",
|
"save_failed": "Kon contact niet opslaan",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetuj",
|
"demo_reset": "Resetuj",
|
||||||
"demo_tour": "Przewodnik",
|
"demo_tour": "Przewodnik",
|
||||||
"tags": "Etykiety",
|
"tags": "Etykiety",
|
||||||
|
"show_all_tags": "Pokaż wszystkie ({count})",
|
||||||
|
"show_fewer_tags": "Pokaż mniej",
|
||||||
"folders": "Foldery",
|
"folders": "Foldery",
|
||||||
"mail": "Poczta",
|
"mail": "Poczta",
|
||||||
"nav_label": "Nawigacja",
|
"nav_label": "Nawigacja",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Drukuj",
|
"print": "Drukuj",
|
||||||
"view_source": "Pokaż źródło",
|
"view_source": "Pokaż źródło",
|
||||||
"export_email": "Eksportuj jako .eml",
|
"export_email": "Eksportuj jako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importuj .eml lub .zip",
|
"import_email": "Importuj .eml lub .zip",
|
||||||
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
||||||
"email_source": "Źródło wiadomości",
|
"email_source": "Źródło wiadomości",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Pokaż kontakt",
|
"view_contact": "Pokaż kontakt",
|
||||||
"message_details": "Szczegóły wiadomości",
|
"message_details": "Szczegóły wiadomości",
|
||||||
"more_reply_options": "Więcej opcji odpowiedzi",
|
"more_reply_options": "Więcej opcji odpowiedzi",
|
||||||
"set_color": "Ustaw etykietę",
|
"set_tag": "Ustaw etykietę",
|
||||||
"tag": "Etykieta",
|
"tag": "Etykieta",
|
||||||
"more_actions": "Więcej działań",
|
"more_actions": "Więcej działań",
|
||||||
"previous": "Poprz.",
|
"previous": "Poprz.",
|
||||||
"next": "Nast.",
|
"next": "Nast.",
|
||||||
"move_to": "Przenieś do...",
|
"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",
|
"more_count": "+{count} więcej",
|
||||||
"characters_count": "{count} znaków",
|
"characters_count": "{count} znaków",
|
||||||
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
|
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "ID wiadomości",
|
"message_id": "ID wiadomości",
|
||||||
"list_info": "Informacje o liście"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Odpowiedz (r)",
|
"reply": "Odpowiedz (r)",
|
||||||
"reply_all": "Odpowiedz wszystkim (a)",
|
"reply_all": "Odpowiedz wszystkim (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Wyświetlana nazwa",
|
"recipient_name_placeholder": "Wyświetlana nazwa",
|
||||||
"autocomplete_search_server": "Szukaj na serwerze",
|
"autocomplete_search_server": "Szukaj na serwerze",
|
||||||
"autocomplete_searching": "Wyszukiwanie...",
|
"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."
|
"send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Etykiety e-mail",
|
"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.",
|
"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ę",
|
"add_keyword": "Dodaj etykietę",
|
||||||
"reset_defaults": "Przywróć domyślne",
|
|
||||||
"label_field": "Nazwa wyświetlana",
|
"label_field": "Nazwa wyświetlana",
|
||||||
"label_placeholder": "np. Praca, Osobiste, Pilne",
|
"label_placeholder": "np. Praca, Osobiste, Pilne",
|
||||||
"id_field": "ID etykiety",
|
"id_field": "ID etykiety",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Dodaj",
|
"add": "Dodaj",
|
||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Przetestuj dźwięk powiadomienia",
|
"test_sound": "Przetestuj dźwięk powiadomienia",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"mark_as_spam": "Zgłoś spam",
|
"mark_as_spam": "Zgłoś spam",
|
||||||
"not_spam": "To nie spam",
|
"not_spam": "To nie spam",
|
||||||
"color_tag": "Etykieta",
|
"tag": "Etykieta",
|
||||||
"remove_color": "Usuń etykietę",
|
|
||||||
"items_selected": "{count} zaznaczonych wiadomości",
|
"items_selected": "{count} zaznaczonych wiadomości",
|
||||||
"edit_draft": "Edytuj szkic",
|
"edit_draft": "Edytuj szkic",
|
||||||
"cancel_scheduled_send": "Anuluj wysyłkę",
|
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Katalog",
|
"section_address_book": "Katalog",
|
||||||
"select_address_book": "Wybierz katalog...",
|
"select_address_book": "Wybierz katalog...",
|
||||||
"section_identity": "Imię i tożsamość",
|
"section_identity": "Imię i tożsamość",
|
||||||
|
"contact_type": "Typ kontaktu",
|
||||||
|
"type_person": "Osoba",
|
||||||
|
"type_organization": "Organizacja",
|
||||||
"section_work": "Praca i organizacja",
|
"section_work": "Praca i organizacja",
|
||||||
"prefix": "Tytuł",
|
"prefix": "Tytuł",
|
||||||
"prefix_placeholder": "Dr, Pan, Pani",
|
"prefix_placeholder": "Dr, Pan, Pani",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
"creating": "Tworzenie...",
|
"creating": "Tworzenie...",
|
||||||
"updating": "Aktualizowanie...",
|
"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_invalid": "Wprowadź prawidłowy adres e-mail",
|
||||||
"email_error_inline": "Nieprawidłowy format adresu e-mail",
|
"email_error_inline": "Nieprawidłowy format adresu e-mail",
|
||||||
"save_failed": "Nie udało się zapisać kontaktu",
|
"save_failed": "Nie udało się zapisać kontaktu",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Repor",
|
"demo_reset": "Repor",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
|
"show_all_tags": "Mostrar tudo ({count})",
|
||||||
|
"show_fewer_tags": "Mostrar menos",
|
||||||
"folders": "Pastas",
|
"folders": "Pastas",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navegação",
|
"nav_label": "Navegação",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código-fonte",
|
"view_source": "Ver código-fonte",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importar .eml ou .zip",
|
"import_email": "Importar .eml ou .zip",
|
||||||
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
||||||
"email_source": "Código-fonte do E-mail",
|
"email_source": "Código-fonte do E-mail",
|
||||||
@@ -324,11 +327,13 @@
|
|||||||
"view_contact": "Ver contato",
|
"view_contact": "Ver contato",
|
||||||
"message_details": "Detalhes da Mensagem",
|
"message_details": "Detalhes da Mensagem",
|
||||||
"more_reply_options": "Mais opções de resposta",
|
"more_reply_options": "Mais opções de resposta",
|
||||||
"set_color": "Definir etiqueta",
|
"set_tag": "Definir etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Mais ações",
|
"more_actions": "Mais ações",
|
||||||
"move_to": "Mover para...",
|
"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",
|
"more_count": "+{count} mais",
|
||||||
"characters_count": "{count} caracteres",
|
"characters_count": "{count} caracteres",
|
||||||
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
||||||
@@ -397,17 +402,6 @@
|
|||||||
"message_id": "ID da Mensagem",
|
"message_id": "ID da Mensagem",
|
||||||
"list_info": "Informações da Lista"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
"reply_all": "Responder a todos (a)",
|
"reply_all": "Responder a todos (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Nome de exibição",
|
"recipient_name_placeholder": "Nome de exibição",
|
||||||
"autocomplete_search_server": "Pesquisar no servidor",
|
"autocomplete_search_server": "Pesquisar no servidor",
|
||||||
"autocomplete_searching": "Pesquisando...",
|
"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."
|
"send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Etiquetas de e-mail",
|
"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.",
|
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
|
||||||
"add_keyword": "Adicionar etiqueta",
|
"add_keyword": "Adicionar etiqueta",
|
||||||
"reset_defaults": "Restaurar padrões",
|
|
||||||
"label_field": "Nome de exibição",
|
"label_field": "Nome de exibição",
|
||||||
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
||||||
"id_field": "ID da etiqueta",
|
"id_field": "ID da etiqueta",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"migrating": "A atualizar etiqueta nos e-mails existentes…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Testar som de notificação",
|
"test_sound": "Testar som de notificação",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"mark_as_spam": "Reportar spam",
|
"mark_as_spam": "Reportar spam",
|
||||||
"not_spam": "Não é spam",
|
"not_spam": "Não é spam",
|
||||||
"color_tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"remove_color": "Remover etiqueta",
|
|
||||||
"items_selected": "{count} e-mails selecionados",
|
"items_selected": "{count} e-mails selecionados",
|
||||||
"edit_draft": "Editar rascunho",
|
"edit_draft": "Editar rascunho",
|
||||||
"cancel_scheduled_send": "Cancelar envio",
|
"cancel_scheduled_send": "Cancelar envio",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Diretório",
|
"section_address_book": "Diretório",
|
||||||
"select_address_book": "Selecionar um diretório...",
|
"select_address_book": "Selecionar um diretório...",
|
||||||
"section_identity": "Nome e identidade",
|
"section_identity": "Nome e identidade",
|
||||||
|
"contact_type": "Tipo de contacto",
|
||||||
|
"type_person": "Pessoa",
|
||||||
|
"type_organization": "Organização",
|
||||||
"section_work": "Trabalho e organização",
|
"section_work": "Trabalho e organização",
|
||||||
"prefix": "Prefixo",
|
"prefix": "Prefixo",
|
||||||
"prefix_placeholder": "Dr., Sr., Sra.",
|
"prefix_placeholder": "Dr., Sr., Sra.",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"creating": "Criando...",
|
"creating": "Criando...",
|
||||||
"updating": "Atualizando...",
|
"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_invalid": "Por favor, insira um endereço de e-mail válido",
|
||||||
"email_error_inline": "Formato de e-mail inválido",
|
"email_error_inline": "Formato de e-mail inválido",
|
||||||
"save_failed": "Falha ao salvar contato",
|
"save_failed": "Falha ao salvar contato",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetare",
|
"demo_reset": "Resetare",
|
||||||
"demo_tour": "Tur de prezentare",
|
"demo_tour": "Tur de prezentare",
|
||||||
"tags": "Etichete",
|
"tags": "Etichete",
|
||||||
|
"show_all_tags": "Afișează tot ({count})",
|
||||||
|
"show_fewer_tags": "Afișează mai puține",
|
||||||
"folders": "Dosare",
|
"folders": "Dosare",
|
||||||
"shared": "Partajat",
|
"shared": "Partajat",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Imprimare",
|
"print": "Imprimare",
|
||||||
"view_source": "Vizualizați sursa",
|
"view_source": "Vizualizați sursa",
|
||||||
"export_email": "Exportați ca fișier .eml",
|
"export_email": "Exportați ca fișier .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importați fișiere .eml sau .zip",
|
"import_email": "Importați fișiere .eml sau .zip",
|
||||||
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
|
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
|
||||||
"email_source": "Sursa e-mailului",
|
"email_source": "Sursa e-mailului",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Vizualizare contact",
|
"view_contact": "Vizualizare contact",
|
||||||
"message_details": "Detalii mesaj",
|
"message_details": "Detalii mesaj",
|
||||||
"more_reply_options": "Mai multe opțiuni de răspuns",
|
"more_reply_options": "Mai multe opțiuni de răspuns",
|
||||||
"set_color": "Setați eticheta",
|
"set_tag": "Setați eticheta",
|
||||||
"tag": "Etichetă",
|
"tag": "Etichetă",
|
||||||
"more_actions": "Alte acțiuni",
|
"more_actions": "Alte acțiuni",
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Următorul",
|
"next": "Următorul",
|
||||||
"move_to": "Mergi la...",
|
"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",
|
"more_count": "+{count} mai multe",
|
||||||
"characters_count": "{count} caractere",
|
"characters_count": "{count} caractere",
|
||||||
"quick_reply_placeholder": "Scrie un răspuns rapid...",
|
"quick_reply_placeholder": "Scrie un răspuns rapid...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "IDul mesajelor",
|
"message_id": "IDul mesajelor",
|
||||||
"list_info": "Informații despre listă"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Răspunde (r)",
|
"reply": "Răspunde (r)",
|
||||||
"reply_all": "Răspunde tuturor (a)",
|
"reply_all": "Răspunde tuturor (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Numele afișat",
|
"recipient_name_placeholder": "Numele afișat",
|
||||||
"autocomplete_search_server": "Caută pe server",
|
"autocomplete_search_server": "Caută pe server",
|
||||||
"autocomplete_searching": "Se caută...",
|
"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."
|
"send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "Etichete de e-mail",
|
"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.",
|
"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ă",
|
"add_keyword": "Adăugați etichetă",
|
||||||
"reset_defaults": "Resezare la setările implicite",
|
|
||||||
"label_field": "Nume afișat",
|
"label_field": "Nume afișat",
|
||||||
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
|
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
|
||||||
"id_field": "EtichetăID",
|
"id_field": "EtichetăID",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "Adăugați",
|
"add": "Adăugați",
|
||||||
"cancel": "Anulează",
|
"cancel": "Anulează",
|
||||||
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Testați sunetul de notificare",
|
"test_sound": "Testați sunetul de notificare",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Șterge",
|
"delete": "Șterge",
|
||||||
"mark_as_spam": "Raportează spamul",
|
"mark_as_spam": "Raportează spamul",
|
||||||
"not_spam": "Nu este spam",
|
"not_spam": "Nu este spam",
|
||||||
"color_tag": "Etichetă",
|
"tag": "Etichetă",
|
||||||
"remove_color": "Eliminați eticheta",
|
|
||||||
"items_selected": "{count} e-mailuri selectate",
|
"items_selected": "{count} e-mailuri selectate",
|
||||||
"edit_draft": "Editează schița",
|
"edit_draft": "Editează schița",
|
||||||
"cancel_scheduled_send": "Anulează trimiterea",
|
"cancel_scheduled_send": "Anulează trimiterea",
|
||||||
@@ -2337,6 +2376,9 @@
|
|||||||
"section_address_book": "Director",
|
"section_address_book": "Director",
|
||||||
"select_address_book": "Selectați un director...",
|
"select_address_book": "Selectați un director...",
|
||||||
"section_identity": "Nume și identitate",
|
"section_identity": "Nume și identitate",
|
||||||
|
"contact_type": "Tip de contact",
|
||||||
|
"type_person": "Persoană",
|
||||||
|
"type_organization": "Organizare",
|
||||||
"section_work": "Muncă și organizare",
|
"section_work": "Muncă și organizare",
|
||||||
"prefix": "Prefix",
|
"prefix": "Prefix",
|
||||||
"prefix_placeholder": "Dr., Dl., Dna.",
|
"prefix_placeholder": "Dr., Dl., Dna.",
|
||||||
@@ -2421,7 +2463,7 @@
|
|||||||
"cancel": "Anulează",
|
"cancel": "Anulează",
|
||||||
"creating": "Se creează...",
|
"creating": "Se creează...",
|
||||||
"updating": "Se actualizează...",
|
"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_invalid": "Vă rugăm să introduceți o adresă de e-mail validă",
|
||||||
"email_error_inline": "Format de e-mail nevalid",
|
"email_error_inline": "Format de e-mail nevalid",
|
||||||
"save_failed": "Nu s-a putut salva contactul",
|
"save_failed": "Nu s-a putut salva contactul",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Сбросить",
|
"demo_reset": "Сбросить",
|
||||||
"demo_tour": "Тур",
|
"demo_tour": "Тур",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
|
"show_all_tags": "Показать все ({count})",
|
||||||
|
"show_fewer_tags": "Показать меньше",
|
||||||
"folders": "Папки",
|
"folders": "Папки",
|
||||||
"mail": "Почта",
|
"mail": "Почта",
|
||||||
"nav_label": "Навигация",
|
"nav_label": "Навигация",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Распечатать",
|
"print": "Распечатать",
|
||||||
"view_source": "Просмотреть исходный код",
|
"view_source": "Просмотреть исходный код",
|
||||||
"export_email": "Экспортировать как .eml",
|
"export_email": "Экспортировать как .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Импортировать .eml или .zip",
|
"import_email": "Импортировать .eml или .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": "Установить тег",
|
||||||
"tag": "Тег",
|
"tag": "Тег",
|
||||||
"more_actions": "Другие действия",
|
"more_actions": "Другие действия",
|
||||||
"previous": "Пред.",
|
"previous": "Пред.",
|
||||||
"next": "След.",
|
"next": "След.",
|
||||||
"move_to": "Переместить в...",
|
"move_to": "Переместить в...",
|
||||||
"remove_color": "Удалить тег",
|
"remove_tag": "Удалить тег",
|
||||||
|
"tag_filter_placeholder": "Фильтр тегов",
|
||||||
|
"tag_no_matches": "Подходящих тегов нет",
|
||||||
"more_count": "+{count} ещё",
|
"more_count": "+{count} ещё",
|
||||||
"characters_count": "{count} символов",
|
"characters_count": "{count} символов",
|
||||||
"quick_reply_placeholder": "Написать быстрый ответ...",
|
"quick_reply_placeholder": "Написать быстрый ответ...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "Идентификатор сообщения",
|
"message_id": "Идентификатор сообщения",
|
||||||
"list_info": "Информация о рассылке"
|
"list_info": "Информация о рассылке"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "Цветной тег",
|
|
||||||
"red": "Красный",
|
|
||||||
"orange": "Оранжевый",
|
|
||||||
"yellow": "Жёлтый",
|
|
||||||
"green": "Зелёный",
|
|
||||||
"blue": "Синий",
|
|
||||||
"purple": "Фиолетовый",
|
|
||||||
"pink": "Розовый",
|
|
||||||
"none": "Нет"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "Ответить (r)",
|
"reply": "Ответить (r)",
|
||||||
"reply_all": "Ответить всем (a)",
|
"reply_all": "Ответить всем (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Отображаемое имя",
|
"recipient_name_placeholder": "Отображаемое имя",
|
||||||
"autocomplete_search_server": "Искать на сервере",
|
"autocomplete_search_server": "Искать на сервере",
|
||||||
"autocomplete_searching": "Поиск...",
|
"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": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
|
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Теги электронной почты",
|
"title": "Теги электронной почты",
|
||||||
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
||||||
"add_keyword": "Добавить тег",
|
"add_keyword": "Добавить тег",
|
||||||
"reset_defaults": "Сбросить по умолчанию",
|
|
||||||
"label_field": "Отображаемое название",
|
"label_field": "Отображаемое название",
|
||||||
"label_placeholder": "напр., Работа, Личное, Срочно",
|
"label_placeholder": "напр., Работа, Личное, Срочно",
|
||||||
"id_field": "Идентификатор тега",
|
"id_field": "Идентификатор тега",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"migrating": "Обновление тега в существующих письмах…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Проверить звук уведомления",
|
"test_sound": "Проверить звук уведомления",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"mark_as_spam": "Отметить как спам",
|
"mark_as_spam": "Отметить как спам",
|
||||||
"not_spam": "Не спам",
|
"not_spam": "Не спам",
|
||||||
"color_tag": "Тег",
|
"tag": "Тег",
|
||||||
"remove_color": "Удалить тег",
|
|
||||||
"items_selected": "{count} писем выбрано",
|
"items_selected": "{count} писем выбрано",
|
||||||
"edit_draft": "Редактировать черновик",
|
"edit_draft": "Редактировать черновик",
|
||||||
"cancel_scheduled_send": "Отменить отправку",
|
"cancel_scheduled_send": "Отменить отправку",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Каталог",
|
"section_address_book": "Каталог",
|
||||||
"select_address_book": "Выберите каталог...",
|
"select_address_book": "Выберите каталог...",
|
||||||
"section_identity": "Имя и личность",
|
"section_identity": "Имя и личность",
|
||||||
|
"contact_type": "Тип контакта",
|
||||||
|
"type_person": "Человек",
|
||||||
|
"type_organization": "Организация",
|
||||||
"section_work": "Работа и организация",
|
"section_work": "Работа и организация",
|
||||||
"prefix": "Префикс",
|
"prefix": "Префикс",
|
||||||
"prefix_placeholder": "Д-р., Г-н., Г-жа.",
|
"prefix_placeholder": "Д-р., Г-н., Г-жа.",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"creating": "Создание...",
|
"creating": "Создание...",
|
||||||
"updating": "Обновление...",
|
"updating": "Обновление...",
|
||||||
"name_required": "Требуется хотя бы имя или фамилия",
|
"name_required": "Укажите имя, фамилию или организацию",
|
||||||
"email_invalid": "Введите корректный адрес электронной почты",
|
"email_invalid": "Введите корректный адрес электронной почты",
|
||||||
"email_error_inline": "Неверный формат email",
|
"email_error_inline": "Неверный формат email",
|
||||||
"save_failed": "Не удалось сохранить контакт",
|
"save_failed": "Не удалось сохранить контакт",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetovať",
|
"demo_reset": "Resetovať",
|
||||||
"demo_tour": "Sprievodca",
|
"demo_tour": "Sprievodca",
|
||||||
"tags": "Štítky",
|
"tags": "Štítky",
|
||||||
|
"show_all_tags": "Zobraziť všetko ({count})",
|
||||||
|
"show_fewer_tags": "Zobraziť menej",
|
||||||
"folders": "Priečinky",
|
"folders": "Priečinky",
|
||||||
"shared": "Zdieľané",
|
"shared": "Zdieľané",
|
||||||
"mail": "Pošta",
|
"mail": "Pošta",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Tlačiť",
|
"print": "Tlačiť",
|
||||||
"view_source": "Zobraziť zdrojový kód",
|
"view_source": "Zobraziť zdrojový kód",
|
||||||
"export_email": "Exportovať ako .eml",
|
"export_email": "Exportovať ako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importovať .eml alebo .zip",
|
"import_email": "Importovať .eml alebo .zip",
|
||||||
"keyboard_shortcuts": "Klávesové skratky (?)",
|
"keyboard_shortcuts": "Klávesové skratky (?)",
|
||||||
"email_source": "Zdrojový kód e-mailu",
|
"email_source": "Zdrojový kód e-mailu",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Zobraziť kontakt",
|
"view_contact": "Zobraziť kontakt",
|
||||||
"message_details": "Podrobnosti správy",
|
"message_details": "Podrobnosti správy",
|
||||||
"more_reply_options": "Viac možností odpovede",
|
"more_reply_options": "Viac možností odpovede",
|
||||||
"set_color": "Nastaviť štítok",
|
"set_tag": "Nastaviť štítok",
|
||||||
"tag": "Štítok",
|
"tag": "Štítok",
|
||||||
"more_actions": "Viac akcií",
|
"more_actions": "Viac akcií",
|
||||||
"previous": "Predchádzajúci",
|
"previous": "Predchádzajúci",
|
||||||
"next": "Ďalší",
|
"next": "Ďalší",
|
||||||
"move_to": "Presunúť do...",
|
"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",
|
"more_count": "+{count} ďalších",
|
||||||
"characters_count": "{count} znakov",
|
"characters_count": "{count} znakov",
|
||||||
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
|
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
|
||||||
@@ -424,17 +429,6 @@
|
|||||||
"message_id": "ID správy",
|
"message_id": "ID správy",
|
||||||
"list_info": "Informácie o zozname"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Odpovedať (r)",
|
"reply": "Odpovedať (r)",
|
||||||
"reply_all": "Odpovedať všetkým (a)",
|
"reply_all": "Odpovedať všetkým (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Zobrazené meno",
|
"recipient_name_placeholder": "Zobrazené meno",
|
||||||
"autocomplete_search_server": "Hľadať na serveri",
|
"autocomplete_search_server": "Hľadať na serveri",
|
||||||
"autocomplete_searching": "Hľadanie...",
|
"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."
|
"send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -986,7 +1012,6 @@
|
|||||||
"title": "E-mailové štítky",
|
"title": "E-mailové štítky",
|
||||||
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
|
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
|
||||||
"add_keyword": "Pridať štítok",
|
"add_keyword": "Pridať štítok",
|
||||||
"reset_defaults": "Obnoviť predvolené",
|
|
||||||
"label_field": "Zobrazovaný názov",
|
"label_field": "Zobrazovaný názov",
|
||||||
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
|
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
|
||||||
"id_field": "ID štítku",
|
"id_field": "ID štítku",
|
||||||
@@ -999,7 +1024,22 @@
|
|||||||
"add": "Pridať",
|
"add": "Pridať",
|
||||||
"cancel": "Zrušiť",
|
"cancel": "Zrušiť",
|
||||||
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Otestovať zvuk oznámenia",
|
"test_sound": "Otestovať zvuk oznámenia",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Odstrániť",
|
"delete": "Odstrániť",
|
||||||
"mark_as_spam": "Nahlásiť spam",
|
"mark_as_spam": "Nahlásiť spam",
|
||||||
"not_spam": "Nie je spam",
|
"not_spam": "Nie je spam",
|
||||||
"color_tag": "Štítok",
|
"tag": "Štítok",
|
||||||
"remove_color": "Odstrániť štítok",
|
|
||||||
"items_selected": "{count} vybraných e-mailov",
|
"items_selected": "{count} vybraných e-mailov",
|
||||||
"edit_draft": "Upraviť koncept",
|
"edit_draft": "Upraviť koncept",
|
||||||
"cancel_scheduled_send": "Zrušiť odoslanie",
|
"cancel_scheduled_send": "Zrušiť odoslanie",
|
||||||
@@ -2337,6 +2376,9 @@
|
|||||||
"section_address_book": "Adresár",
|
"section_address_book": "Adresár",
|
||||||
"select_address_book": "Vyberte adresár...",
|
"select_address_book": "Vyberte adresár...",
|
||||||
"section_identity": "Meno a identita",
|
"section_identity": "Meno a identita",
|
||||||
|
"contact_type": "Typ kontaktu",
|
||||||
|
"type_person": "Osoba",
|
||||||
|
"type_organization": "Organizácia",
|
||||||
"section_work": "Práca a organizácia",
|
"section_work": "Práca a organizácia",
|
||||||
"prefix": "Titul",
|
"prefix": "Titul",
|
||||||
"prefix_placeholder": "Dr., Pán, Pani",
|
"prefix_placeholder": "Dr., Pán, Pani",
|
||||||
@@ -2421,7 +2463,7 @@
|
|||||||
"cancel": "Zrušiť",
|
"cancel": "Zrušiť",
|
||||||
"creating": "Vytváranie...",
|
"creating": "Vytváranie...",
|
||||||
"updating": "Aktualizovanie...",
|
"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_invalid": "Zadajte platnú e-mailovú adresu",
|
||||||
"email_error_inline": "Neplatný formát e-mailovej adresy",
|
"email_error_inline": "Neplatný formát e-mailovej adresy",
|
||||||
"save_failed": "Uloženie kontaktu zlyhalo",
|
"save_failed": "Uloženie kontaktu zlyhalo",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Sıfırla",
|
"demo_reset": "Sıfırla",
|
||||||
"demo_tour": "Tur",
|
"demo_tour": "Tur",
|
||||||
"tags": "Etiketler",
|
"tags": "Etiketler",
|
||||||
|
"show_all_tags": "Tümünü göster ({count})",
|
||||||
|
"show_fewer_tags": "Daha az göster",
|
||||||
"folders": "Klasörler",
|
"folders": "Klasörler",
|
||||||
"shared": "Paylaşılan",
|
"shared": "Paylaşılan",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Yazdır",
|
"print": "Yazdır",
|
||||||
"view_source": "Kaynağı görüntüle",
|
"view_source": "Kaynağı görüntüle",
|
||||||
"export_email": ".eml olarak dışa aktar",
|
"export_email": ".eml olarak dışa aktar",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml veya .zip içe aktar",
|
"import_email": ".eml veya .zip içe aktar",
|
||||||
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
||||||
"email_source": "E-posta Kaynağı",
|
"email_source": "E-posta Kaynağı",
|
||||||
@@ -324,13 +327,15 @@
|
|||||||
"view_contact": "Kişiyi görüntüle",
|
"view_contact": "Kişiyi görüntüle",
|
||||||
"message_details": "İleti Ayrıntıları",
|
"message_details": "İleti Ayrıntıları",
|
||||||
"more_reply_options": "Daha fazla yanıt seçeneği",
|
"more_reply_options": "Daha fazla yanıt seçeneği",
|
||||||
"set_color": "Etiket ayarla",
|
"set_tag": "Etiket ayarla",
|
||||||
"tag": "Etiket",
|
"tag": "Etiket",
|
||||||
"more_actions": "Diğer işlemler",
|
"more_actions": "Diğer işlemler",
|
||||||
"previous": "Önceki",
|
"previous": "Önceki",
|
||||||
"next": "Sonraki",
|
"next": "Sonraki",
|
||||||
"move_to": "Şuraya taşı...",
|
"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",
|
"more_count": "+{count} daha",
|
||||||
"characters_count": "{count} karakter",
|
"characters_count": "{count} karakter",
|
||||||
"quick_reply_placeholder": "Hızlı yanıt yazın...",
|
"quick_reply_placeholder": "Hızlı yanıt yazın...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "İleti Kimliği",
|
"message_id": "İleti Kimliği",
|
||||||
"list_info": "Liste Bilgisi"
|
"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": {
|
"tooltips": {
|
||||||
"reply": "Yanıtla (r)",
|
"reply": "Yanıtla (r)",
|
||||||
"reply_all": "Tümünü Yanıtla (a)",
|
"reply_all": "Tümünü Yanıtla (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Görünen ad",
|
"recipient_name_placeholder": "Görünen ad",
|
||||||
"autocomplete_search_server": "Sunucuda ara",
|
"autocomplete_search_server": "Sunucuda ara",
|
||||||
"autocomplete_searching": "Aranıyor...",
|
"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."
|
"send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "E-posta Etiketleri",
|
"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.",
|
"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",
|
"add_keyword": "Etiket Ekle",
|
||||||
"reset_defaults": "Varsayılanlara Sıfırla",
|
|
||||||
"label_field": "Görünen Ad",
|
"label_field": "Görünen Ad",
|
||||||
"label_placeholder": "ör. İş, Kişisel, Acil",
|
"label_placeholder": "ör. İş, Kişisel, Acil",
|
||||||
"id_field": "Etiket Kimliği",
|
"id_field": "Etiket Kimliği",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "Ekle",
|
"add": "Ekle",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Bildirim sesini test et",
|
"test_sound": "Bildirim sesini test et",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"mark_as_spam": "Spam bildir",
|
"mark_as_spam": "Spam bildir",
|
||||||
"not_spam": "Spam değil",
|
"not_spam": "Spam değil",
|
||||||
"color_tag": "Etiket",
|
"tag": "Etiket",
|
||||||
"remove_color": "Etiketi kaldır",
|
|
||||||
"items_selected": "{count} e-posta seçildi",
|
"items_selected": "{count} e-posta seçildi",
|
||||||
"edit_draft": "Taslağı Düzenle",
|
"edit_draft": "Taslağı Düzenle",
|
||||||
"cancel_scheduled_send": "Göndermeyi iptal et",
|
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Dizin",
|
"section_address_book": "Dizin",
|
||||||
"select_address_book": "Bir dizin seçin...",
|
"select_address_book": "Bir dizin seçin...",
|
||||||
"section_identity": "Ad ve Kimlik",
|
"section_identity": "Ad ve Kimlik",
|
||||||
|
"contact_type": "Kişi türü",
|
||||||
|
"type_person": "Kişi",
|
||||||
|
"type_organization": "Kuruluş",
|
||||||
"section_work": "İş ve Kuruluş",
|
"section_work": "İş ve Kuruluş",
|
||||||
"prefix": "Ön Ek",
|
"prefix": "Ön Ek",
|
||||||
"prefix_placeholder": "Dr., Bay, Bayan",
|
"prefix_placeholder": "Dr., Bay, Bayan",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
"creating": "Oluşturuluyor...",
|
"creating": "Oluşturuluyor...",
|
||||||
"updating": "Güncelleniyor...",
|
"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_invalid": "Lütfen geçerli bir e-posta adresi girin",
|
||||||
"email_error_inline": "Geçersiz e-posta biçimi",
|
"email_error_inline": "Geçersiz e-posta biçimi",
|
||||||
"save_failed": "Kişi kaydedilemedi",
|
"save_failed": "Kişi kaydedilemedi",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Скинути",
|
"demo_reset": "Скинути",
|
||||||
"demo_tour": "Тур",
|
"demo_tour": "Тур",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
|
"show_all_tags": "Показати всі ({count})",
|
||||||
|
"show_fewer_tags": "Показати менше",
|
||||||
"folders": "Папки",
|
"folders": "Папки",
|
||||||
"mail": "Пошта",
|
"mail": "Пошта",
|
||||||
"nav_label": "Навігація",
|
"nav_label": "Навігація",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "Роздрукувати",
|
"print": "Роздрукувати",
|
||||||
"view_source": "Переглянути джерело",
|
"view_source": "Переглянути джерело",
|
||||||
"export_email": "Експортувати як .eml",
|
"export_email": "Експортувати як .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Імпорт .eml або .zip",
|
"import_email": "Імпорт .eml або .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": "Встановити тег",
|
||||||
"tag": "Тег",
|
"tag": "Тег",
|
||||||
"more_actions": "Більше дій",
|
"more_actions": "Більше дій",
|
||||||
"previous": "попередня",
|
"previous": "попередня",
|
||||||
"next": "Далі",
|
"next": "Далі",
|
||||||
"move_to": "Перейти до...",
|
"move_to": "Перейти до...",
|
||||||
"remove_color": "Видалити тег",
|
"remove_tag": "Видалити тег",
|
||||||
|
"tag_filter_placeholder": "Фільтр тегів",
|
||||||
|
"tag_no_matches": "Немає відповідних тегів",
|
||||||
"more_count": "+ ще {count}",
|
"more_count": "+ ще {count}",
|
||||||
"characters_count": "{count} символів",
|
"characters_count": "{count} символів",
|
||||||
"quick_reply_placeholder": "Напишіть швидку відповідь...",
|
"quick_reply_placeholder": "Напишіть швидку відповідь...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "ID повідомлення",
|
"message_id": "ID повідомлення",
|
||||||
"list_info": "Інформація про список"
|
"list_info": "Інформація про список"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "Кольоровий тег",
|
|
||||||
"red": "Червоний",
|
|
||||||
"orange": "Помаранчевий",
|
|
||||||
"yellow": "Жовтий",
|
|
||||||
"green": "Зелений",
|
|
||||||
"blue": "Синій",
|
|
||||||
"purple": "Фіолетовий",
|
|
||||||
"pink": "Рожевий",
|
|
||||||
"none": "Жодного"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "Відповісти (р)",
|
"reply": "Відповісти (р)",
|
||||||
"reply_all": "Відповісти всім (а)",
|
"reply_all": "Відповісти всім (а)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "Відображуване ім'я",
|
"recipient_name_placeholder": "Відображуване ім'я",
|
||||||
"autocomplete_search_server": "Шукати на сервері",
|
"autocomplete_search_server": "Шукати на сервері",
|
||||||
"autocomplete_searching": "Пошук...",
|
"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": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
|
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "Ключові слова електронної пошти",
|
"title": "Ключові слова електронної пошти",
|
||||||
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
|
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
|
||||||
"add_keyword": "Додати ключове слово",
|
"add_keyword": "Додати ключове слово",
|
||||||
"reset_defaults": "Скинути до значень за замовчуванням",
|
|
||||||
"label_field": "Відображуване ім'я",
|
"label_field": "Відображуване ім'я",
|
||||||
"label_placeholder": "напр. Робота, Особиста, Терміново",
|
"label_placeholder": "напр. Робота, Особиста, Терміново",
|
||||||
"id_field": "ID ключового слова",
|
"id_field": "ID ключового слова",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "додати",
|
"add": "додати",
|
||||||
"cancel": "Скасувати",
|
"cancel": "Скасувати",
|
||||||
"migrating": "Оновлення ключового слова в наявних електронних листах…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Тестовий звук сповіщення",
|
"test_sound": "Тестовий звук сповіщення",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
"mark_as_spam": "Повідомити про спам",
|
"mark_as_spam": "Повідомити про спам",
|
||||||
"not_spam": "Не спам",
|
"not_spam": "Не спам",
|
||||||
"color_tag": "Мітка",
|
"tag": "Мітка",
|
||||||
"remove_color": "Видалити мітку",
|
|
||||||
"items_selected": "Вибрано електронних листів: {count}",
|
"items_selected": "Вибрано електронних листів: {count}",
|
||||||
"edit_draft": "Редагувати чернетку",
|
"edit_draft": "Редагувати чернетку",
|
||||||
"cancel_scheduled_send": "Скасувати надсилання",
|
"cancel_scheduled_send": "Скасувати надсилання",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "Довідник",
|
"section_address_book": "Довідник",
|
||||||
"select_address_book": "Виберіть каталог...",
|
"select_address_book": "Виберіть каталог...",
|
||||||
"section_identity": "Ім'я та ідентифікація",
|
"section_identity": "Ім'я та ідентифікація",
|
||||||
|
"contact_type": "Тип контакту",
|
||||||
|
"type_person": "Людина",
|
||||||
|
"type_organization": "організація",
|
||||||
"section_work": "Робота та організація",
|
"section_work": "Робота та організація",
|
||||||
"prefix": "Префікс",
|
"prefix": "Префікс",
|
||||||
"prefix_placeholder": "доктор, пан, місіс",
|
"prefix_placeholder": "доктор, пан, місіс",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "Скасувати",
|
"cancel": "Скасувати",
|
||||||
"creating": "Створення...",
|
"creating": "Створення...",
|
||||||
"updating": "Оновлення...",
|
"updating": "Оновлення...",
|
||||||
"name_required": "Потрібне принаймні ім’я або прізвище",
|
"name_required": "Вкажіть ім'я, прізвище або організацію",
|
||||||
"email_invalid": "Введіть дійсну електронну адресу",
|
"email_invalid": "Введіть дійсну електронну адресу",
|
||||||
"email_error_inline": "Недійсний формат електронної пошти",
|
"email_error_inline": "Недійсний формат електронної пошти",
|
||||||
"save_failed": "Не вдалося зберегти контакт",
|
"save_failed": "Не вдалося зберегти контакт",
|
||||||
|
|||||||
+60
-18
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "重置",
|
"demo_reset": "重置",
|
||||||
"demo_tour": "引导",
|
"demo_tour": "引导",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
|
"show_all_tags": "显示全部({count})",
|
||||||
|
"show_fewer_tags": "收起",
|
||||||
"folders": "文件夹",
|
"folders": "文件夹",
|
||||||
"mail": "邮件",
|
"mail": "邮件",
|
||||||
"nav_label": "导航",
|
"nav_label": "导航",
|
||||||
@@ -298,6 +300,7 @@
|
|||||||
"print": "打印",
|
"print": "打印",
|
||||||
"view_source": "查看源码",
|
"view_source": "查看源码",
|
||||||
"export_email": "导出为 .eml",
|
"export_email": "导出为 .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "导入 .eml 或 .zip",
|
"import_email": "导入 .eml 或 .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": "设置颜色标签",
|
||||||
"tag": "标签",
|
"tag": "标签",
|
||||||
"more_actions": "更多操作",
|
"more_actions": "更多操作",
|
||||||
"previous": "上一封",
|
"previous": "上一封",
|
||||||
"next": "下一封",
|
"next": "下一封",
|
||||||
"move_to": "移动到…",
|
"move_to": "移动到…",
|
||||||
"remove_color": "删除标签",
|
"remove_tag": "删除标签",
|
||||||
|
"tag_filter_placeholder": "筛选标签",
|
||||||
|
"tag_no_matches": "没有匹配的标签",
|
||||||
"more_count": "+{count} 更多",
|
"more_count": "+{count} 更多",
|
||||||
"characters_count": "{count} 个字符",
|
"characters_count": "{count} 个字符",
|
||||||
"quick_reply_placeholder": "快速回复...",
|
"quick_reply_placeholder": "快速回复...",
|
||||||
@@ -399,17 +404,6 @@
|
|||||||
"message_id": "消息 ID",
|
"message_id": "消息 ID",
|
||||||
"list_info": "邮件列表信息"
|
"list_info": "邮件列表信息"
|
||||||
},
|
},
|
||||||
"color_tag": {
|
|
||||||
"title": "颜色标签",
|
|
||||||
"red": "红色",
|
|
||||||
"orange": "橙色",
|
|
||||||
"yellow": "黄色",
|
|
||||||
"green": "绿色",
|
|
||||||
"blue": "蓝色",
|
|
||||||
"purple": "紫色",
|
|
||||||
"pink": "粉色",
|
|
||||||
"none": "无"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"reply": "回复 (r)",
|
"reply": "回复 (r)",
|
||||||
"reply_all": "全部回复 (a)",
|
"reply_all": "全部回复 (a)",
|
||||||
@@ -686,6 +680,38 @@
|
|||||||
"recipient_name_placeholder": "显示名称",
|
"recipient_name_placeholder": "显示名称",
|
||||||
"autocomplete_search_server": "在服务器上搜索",
|
"autocomplete_search_server": "在服务器上搜索",
|
||||||
"autocomplete_searching": "搜索中...",
|
"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": "已发送,但发送后的清理失败,可能会残留旧草稿。"
|
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
@@ -983,7 +1009,6 @@
|
|||||||
"title": "电子邮件标签",
|
"title": "电子邮件标签",
|
||||||
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
|
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
|
||||||
"add_keyword": "添加标签",
|
"add_keyword": "添加标签",
|
||||||
"reset_defaults": "重置为默认值",
|
|
||||||
"label_field": "显示名称",
|
"label_field": "显示名称",
|
||||||
"label_placeholder": "例如工作、个人、紧急",
|
"label_placeholder": "例如工作、个人、紧急",
|
||||||
"id_field": "标签ID",
|
"id_field": "标签ID",
|
||||||
@@ -996,7 +1021,22 @@
|
|||||||
"add": "添加",
|
"add": "添加",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"migrating": "正在更新现有邮件的标签…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "测试通知声音",
|
"test_sound": "测试通知声音",
|
||||||
@@ -2001,8 +2041,7 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"mark_as_spam": "举报垃圾邮件",
|
"mark_as_spam": "举报垃圾邮件",
|
||||||
"not_spam": "不是垃圾邮件",
|
"not_spam": "不是垃圾邮件",
|
||||||
"color_tag": "标签",
|
"tag": "标签",
|
||||||
"remove_color": "删除标签",
|
|
||||||
"items_selected": "已选择 {count} 封邮件",
|
"items_selected": "已选择 {count} 封邮件",
|
||||||
"edit_draft": "编辑草稿",
|
"edit_draft": "编辑草稿",
|
||||||
"cancel_scheduled_send": "取消发送",
|
"cancel_scheduled_send": "取消发送",
|
||||||
@@ -2336,6 +2375,9 @@
|
|||||||
"section_address_book": "地址簿",
|
"section_address_book": "地址簿",
|
||||||
"select_address_book": "选择地址簿...",
|
"select_address_book": "选择地址簿...",
|
||||||
"section_identity": "姓名和身份",
|
"section_identity": "姓名和身份",
|
||||||
|
"contact_type": "联系人类型",
|
||||||
|
"type_person": "个人",
|
||||||
|
"type_organization": "组织",
|
||||||
"section_work": "工作与组织",
|
"section_work": "工作与组织",
|
||||||
"prefix": "前缀",
|
"prefix": "前缀",
|
||||||
"prefix_placeholder": "博士、先生、女士",
|
"prefix_placeholder": "博士、先生、女士",
|
||||||
@@ -2420,7 +2462,7 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"creating": "创建中...",
|
"creating": "创建中...",
|
||||||
"updating": "更新中...",
|
"updating": "更新中...",
|
||||||
"name_required": "至少需要名字或姓氏",
|
"name_required": "请输入名字、姓氏或组织",
|
||||||
"email_invalid": "请输入有效的邮箱地址",
|
"email_invalid": "请输入有效的邮箱地址",
|
||||||
"email_error_inline": "邮箱地址格式无效",
|
"email_error_inline": "邮箱地址格式无效",
|
||||||
"save_failed": "保存联系人失败",
|
"save_failed": "保存联系人失败",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const nextConfig: NextConfig = {
|
|||||||
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||||
NEXT_PUBLIC_BASE_PATH: basePath,
|
NEXT_PUBLIC_BASE_PATH: basePath,
|
||||||
|
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+11
@@ -73,6 +73,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
@@ -9666,6 +9667,16 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.5"
|
"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
Reference in New Issue
Block a user