1 parent
beae94e932
commit
de4d70970c
51 files changed
+4034
-421
No files matched your search
@@ -1,5 +1,30 @@
|
||||
# RustBox changelog
|
||||
|
||||
### 0.2.24
|
||||
|
||||
- Fixed **Style gradients** doing nothing at the default *Standard* depth: gradients were only part of the immersive tokens. They now live in a dedicated gradient layer (`59-skin-gradients.css`) that applies at every style depth.
|
||||
- Every predefined style except the RustBox custom style now has gradients behind the switch — app-bar gloss, toolbar tint, sidebar fade, selection sweep, glossy primary/compose buttons and an accent backdrop glow — with signature gradients for Roundcube Classic/Larry, GitHub, Discord, Reddit, Yahoo, Dracula, Tokyo Night, Synthwave, Aqua, Terminal, Retro 95 and others.
|
||||
- Added retro styles: **Windows XP** (Luna title bar, green Start-style compose button, task-pane sidebar), **Mac OS X Aqua** (pinstripes, gel buttons, Aqua selection), **iOS 6** (glossy navigation bar, linen, glossy buttons), **Windows 7 Aero** (Harmony backdrop, glass app bar), **Frutiger Aero** (sky-and-grass backdrop, bubble buttons) and **BeOS** (yellow tab app bar, bevelled controls).
|
||||
- Added a **By time of day** theme: light between two configurable times, dark otherwise (`theme_light_from` / `theme_dark_from`, migration `V054`).
|
||||
- Added an optional **account colour** shown as a stripe on the app bar and a dot next to the address (`account_color`, `V054`); display preferences are per account, so each account can have its own.
|
||||
- Custom colours now show a live **low-contrast warning** based on WCAG thresholds.
|
||||
- The message list shows **skeleton rows** while a folder loads, and switching theme or style cross-fades briefly (off with reduced motion).
|
||||
- Fixed the default RustBox selection colour being dark in the light theme.
|
||||
- Fixed subjects being squeezed to a few characters by the snippet in message rows.
|
||||
- Flagged messages are marked with a red side bar instead of a bottom line.
|
||||
- Reader-toolbar button labels now collapse below 820px instead of making the toolbar scroll.
|
||||
- Calendar: added an optional **second reminder** per event (for example *1 day before*; choices from *at start* to *1 week before*) and **default reminders for new events** in Settings → Notifications. All-day events are reminded at the start of the working day (default 09:00), so "1 day before" fires the previous morning. Both reminders are exported to ICS. Migration `V054`.
|
||||
- Fixed clearing an event reminder: choosing *None* in the editor now removes it instead of keeping the old value.
|
||||
- The interface-style list is grouped into categories (RustBox & classic, Roundcube, mail apps, apps, editor themes, atmospheric, retro) and uses short, consistent names in English and Polish (e.g. *Gmail*, *Windows XP*, *Czysty*, *Papier*).
|
||||
- Filters: **Apply now** runs a rule on mail that is already in a folder (Sieve itself only sees new mail). *Check matches* in the rule editor previews how many messages in the chosen folder match; each rule in the list has its own *Apply now*. Matching uses IMAP `UID SEARCH` on the server (`POST /api/filters/match`) with an exact lower bound and a superset upper bound; messages in between are decided from the local cache, and anything that still cannot be decided exactly is reported and left untouched. Flags and labels are applied first, then copies, then a single move. Redirect, discard and *set flags* are skipped for existing messages and listed in the confirmation.
|
||||
- Filters: **exceptions** (*Except if …*, written as `allof (…, not anyof (…))` and read back by the editor), new conditions **To address**, **Cc address**, **Size larger/smaller than** (`500K`, `2M`, `1G`), **Has attachment** (Sieve `mime`) and **High priority**, and new actions **Add label** (RustBox label, via its IMAP keyword) and **Move to Trash**.
|
||||
- Filters: **templates** at the top of the rule editor (sender to folder, subject words, VIP flag, newsletters, alias, large attachments, forward a copy), an offer to **create a missing target folder** when a rule moves mail to one, and a message context-menu entry **Always move messages from this sender…** that adds the rule and offers to apply it to the current folder. The quick filter dialog can also apply the new rule to existing messages.
|
||||
- Filter summaries (*When / Then*, AND / OR / NOT, *except if*) are translated.
|
||||
- Confirmation dialogs translate their button labels.
|
||||
- The message context menu is shorter: forwarding, labels, snooze, filters, sender actions and technical tools (headers, source, Message-ID, .eml, print) are grouped into submenus. The menu and its submenus never exceed the window height (they scroll instead), so *Delete* stays reachable on small screens.
|
||||
- `skin_depth` and `skin_gradients` remain in migration `V053`; the filter work needs no migration.
|
||||
- Bumped the application/source release to 0.2.24.
|
||||
|
||||
### 0.2.23
|
||||
|
||||
- Added **Style depth** to Appearance (`skin_depth`, migration `V053`). *Colors only* applies just a predefined style's palette and keeps every layout control editable; *Standard* (default) is the previous behavior — palette plus the style's fixed layout profile; *Immersive* additionally reshapes the interface with the style's own typography, control/button/avatar shapes, floating or flush panes, app bar and search field, sidebar colors, selection markers, card-style rows, primary/compose buttons and backdrop (gradients, glass blur, bevels). Floating-pane geometry applies only to the desktop three-pane layout; phones and tablets keep the drawer layout.
|
||||
|
||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustbox"
|
||||
version = "0.2.23"
|
||||
version = "0.2.24"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustbox"
|
||||
version = "0.2.23"
|
||||
version = "0.2.24"
|
||||
edition = "2024"
|
||||
rust-version = "1.99.1"
|
||||
default-run = "rustbox"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RustBox attribution / notice
|
||||
|
||||
RustBox 0.2.23 is derived from the Oxi webmail project:
|
||||
RustBox 0.2.24 is derived from the Oxi webmail project:
|
||||
|
||||
- Upstream: `c0h1b4/oxi`
|
||||
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# RustBox 0.2.23
|
||||
# RustBox 0.2.24
|
||||
|
||||
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
||||
|
||||
@@ -67,7 +67,7 @@ Edit `config.dev.toml` before the first run:
|
||||
|
||||
```toml
|
||||
default_theme = "system" # system | dark | light
|
||||
default_interface_skin = "rustbox" # rustbox | gmail | outlook | apple-mail | github | slack | dracula | catppuccin | tokyo-night | paper | terminal | aqua | material | retro | ... (full list in config.example.toml)
|
||||
default_interface_skin = "rustbox" # rustbox | gmail | outlook | apple-mail | github | slack | dracula | catppuccin | tokyo-night | paper | terminal | aqua | material | retro | winxp | osx | ios6 | win7 | frutiger-aero | beos | ... (full list in config.example.toml)
|
||||
log_level = "info" # RustBox app level; dependencies default to WARN. RUST_LOG can override the full filter.
|
||||
# Recommended dev filter: warn,rustbox=debug,tower_http=info
|
||||
# Avoid a global RUST_LOG=debug/info unless diagnosing a dependency: third-party
|
||||
@@ -183,7 +183,7 @@ rustbox search reindex
|
||||
|
||||
Ready Docker examples are in `docker/`: standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch.
|
||||
|
||||
All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. Migrations V047-V052 are consolidated into the V052 compatibility baseline; databases older than V052 must first be upgraded with a release that can reach that baseline (0.2.22). RustBox 0.2.23 adds V053 for the appearance `skin_depth` and `skin_gradients` settings; fresh schemas already contain the column, while existing V052 tenants migrate automatically. V054 is the next migration number. See `migrations/README.md`.
|
||||
All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. Migrations V047-V052 are consolidated into the V052 compatibility baseline; databases older than V052 must first be upgraded with a release that can reach that baseline (0.2.22). RustBox 0.2.23 adds V053 for the appearance `skin_depth` and `skin_gradients` settings and 0.2.24 adds V054 for the time-of-day theme and per-account colour; fresh schemas already contain the columns, while existing tenants migrate automatically. V055 is the next migration number. See `migrations/README.md`.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
+17
-4
@@ -1,4 +1,16 @@
|
||||
RustBox 0.2.23 release manifest
|
||||
RustBox 0.2.24 release manifest
|
||||
|
||||
0.2.24 release changes:
|
||||
- fixes the "Style gradients" switch having no visible effect at the default "standard" depth: gradients moved out of the immersive-only tokens into a dedicated gradient layer (`59-skin-gradients.css`) that applies at every style depth
|
||||
- every predefined style except the RustBox custom style now has gradients behind the switch: translucent defaults (app-bar gloss, toolbar tint, sidebar fade, selection sweep, glossy primary/compose buttons, accent backdrop glow) plus signature gradients for styles such as Roundcube Classic/Larry, GitHub, Discord, Reddit, Yahoo, Dracula, Tokyo Night, Synthwave, Aqua, Terminal and Retro 95
|
||||
- adds 6 retro predefined styles: Windows XP inspired (Luna title bar, green Start-style compose button, task-pane sidebar), Mac OS X Aqua inspired (pinstripes, gel buttons, Aqua selection), iOS 6 inspired (glossy navigation bar, linen, glossy buttons), Windows 7 Aero inspired (Harmony backdrop, glass app bar), Frutiger Aero and BeOS inspired (yellow tab app bar)
|
||||
- adds a time-of-day theme (`theme = schedule`, light/dark switch times) and an optional per-account colour (app-bar stripe and address dot), persisted by migration V054
|
||||
- adds an optional second calendar reminder per event (e.g. 1 day before; choices up to 1 week) plus default reminders for new events in Settings → Notifications; all-day events are reminded at the start of the working day; clearing a reminder in the event editor now really clears it (V054)
|
||||
- groups the interface-style selector into categories (RustBox & classic, Roundcube, mail apps, apps, editor themes, atmospheric, retro) with short, consistent names in English and Polish
|
||||
- adds a live low-contrast warning for custom colours (WCAG thresholds), skeleton rows while a folder loads and a short cross-fade when the theme or style changes
|
||||
- fixes the RustBox default selection colour being dark in the light theme, subjects being squeezed by snippets in message rows, and the flagged-message marker (now a side bar instead of a bottom line); reader-toolbar labels collapse below 820px instead of overflowing
|
||||
- filters: "Apply now" runs a visual Sieve rule on messages already in a folder; `POST /api/filters/match` finds matches with IMAP `UID SEARCH` (exact lower bound, superset upper bound, the gap decided from the local cache; undecidable messages are reported and skipped), and the client applies flags/labels, copies and a final move; per-rule "Apply now" in the rule list and an "apply to existing messages" option in the quick filter dialog
|
||||
- filters: exceptions ("except if"), new conditions (To only, Cc only, size larger/smaller, has attachment, high priority), new actions (add RustBox label, move to Trash), rule templates, creating a missing target folder, "Always move messages from this sender…" in the message context menu, and translated rule summaries
|
||||
|
||||
0.2.23 release changes:
|
||||
- adds a "Style depth" appearance setting (`skin_depth`, migration V053): `colors` applies only a predefined style's palette, `standard` (default, previous behavior) adds its fixed layout profile, `immersive` additionally applies its structural design tokens — typography, control/button/avatar shapes, floating or flush panes, app-bar and search treatment, selection markers, row cards and backdrop
|
||||
@@ -83,7 +95,8 @@ Database/schema:
|
||||
- compatibility baseline is V052; former V047-V052 changes are consolidated into `schema.sql` and their standalone migrations are removed
|
||||
- databases older than V052 must be upgraded with a V052-capable RustBox release (0.2.22) before using this build
|
||||
- V053 adds `display_preferences.skin_depth` (default `standard`) and `display_preferences.skin_gradients` (default off) to existing V052 tenants
|
||||
- next migration number: V054
|
||||
- V054 adds `theme_light_from`, `theme_dark_from`, `account_color`, `calendar_events.reminder_extra_minutes` and the calendar default reminders to existing V053 tenants
|
||||
- next migration number: V055
|
||||
|
||||
Docker examples:
|
||||
- docker/compose-single-sqlite.yml
|
||||
@@ -93,14 +106,14 @@ Docker examples:
|
||||
|
||||
Operations/docs:
|
||||
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets
|
||||
- README.md documents 0.2.23 configuration
|
||||
- README.md documents 0.2.24 configuration
|
||||
- docker/README.md documents Compose variants
|
||||
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
||||
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
||||
|
||||
Build/release metadata:
|
||||
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
|
||||
- Cargo.toml version = 0.2.23
|
||||
- Cargo.toml version = 0.2.24
|
||||
- rust-toolchain.toml pins Rust 1.99.1
|
||||
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
||||
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
||||
+12
-6
@@ -1,4 +1,10 @@
|
||||
RustBox 0.2.23 - final source validation
|
||||
RustBox 0.2.24 - final source validation
|
||||
|
||||
0.2.24 scope:
|
||||
- "Style gradients" now applies at every style depth through `59-skin-gradients.css`, to every predefined style except RustBox
|
||||
- adds Windows XP, Mac OS X Aqua, iOS 6, Windows 7 Aero, Frutiger Aero and BeOS inspired styles (43 styles in total)
|
||||
- time-of-day theme, per-account colour and second calendar reminder with defaults (V054), grouped style selector with short names, custom-colour contrast warning, message-list skeleton, theme cross-fade
|
||||
- fixes: light-theme selection colour, subject truncation, flagged marker, reader-toolbar overflow
|
||||
|
||||
0.2.23 scope:
|
||||
- adds Appearance "Style depth" (`skin_depth`, V053): colors only / standard (default) / immersive
|
||||
@@ -52,18 +58,18 @@ RustBox 0.2.23 - final source validation
|
||||
- PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility
|
||||
- Composing shows only compact attachment/message size-limit values on one line
|
||||
- 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact
|
||||
- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients` to existing V052 tenants; V054 is next
|
||||
- version metadata identifies 0.2.23
|
||||
- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 is next
|
||||
- version metadata identifies 0.2.24
|
||||
|
||||
Completed in this environment:
|
||||
- offline release validator: scripts/validate-release-static.py (432/432 checks passed)
|
||||
- offline release validator: scripts/validate-release-static.py (455/455 checks passed)
|
||||
- browser JavaScript assembled in manifest order passes `node --check`
|
||||
- all JSON files parse successfully
|
||||
- Python helper scripts compile successfully
|
||||
- shell scripts pass `bash -n`
|
||||
- release metadata consistently identifies 0.2.23
|
||||
- release metadata consistently identifies 0.2.24
|
||||
- V052 baseline consolidation plus the V053 skin-depth migration are validated offline
|
||||
- all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors
|
||||
- the new 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors
|
||||
|
||||
|
||||
Not executable in this packaging environment:
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ default_theme = "system"
|
||||
# proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail,
|
||||
# yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion, dracula,
|
||||
# catppuccin, solarized, gruvbox, tokyo-night, rose-pine, everforest, synthwave,
|
||||
# paper, terminal, aqua, linear, material, retro
|
||||
# paper, terminal, aqua, linear, material, retro, winxp, osx, ios6, win7,
|
||||
# frutiger-aero, beos
|
||||
default_interface_skin = "gmail"
|
||||
data_dir = "/var/lib/rustbox"
|
||||
log_dir = "/var/log/rustbox"
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
# RustBox 0.2.23 - implementation status
|
||||
# RustBox 0.2.24 - implementation status
|
||||
|
||||
- Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification.
|
||||
This file tracks the `0.2.23` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
|
||||
This file tracks the `0.2.24` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
|
||||
|
||||
## 0.2.24 style gradients and retro styles
|
||||
|
||||
- "Style gradients" (`skin_gradients`) now works at every style depth. Gradients live only in `59-skin-gradients.css`, generated from one table: `--grad-backdrop`, `--grad-appbar`, `--grad-toolbar`, `--grad-sidebar`, `--grad-selected` and `--grad-primary` are painted as `background-image` layers over the colours chosen by the palette and depth, so translucent defaults work for every style; signature styles override them with opaque gradients and declare matching app-bar / button text colours. The RustBox custom style is excluded. `57-skin-tokens.css` no longer contains gradients.
|
||||
- Adds Windows XP, Mac OS X Aqua, iOS 6, Windows 7 Aero, Frutiger Aero and BeOS inspired styles, each with palettes, a layout profile, structural tokens and signature gradients.
|
||||
- Theme "By time of day" (`theme = schedule`, `theme_light_from` / `theme_dark_from`, migration V054): evaluated in the browser's local time, re-checked every minute.
|
||||
- Optional per-account colour (`account_color`, V054): 3px stripe on the app bar and a dot before the account address. Display preferences are stored per account, so each account keeps its own colour.
|
||||
- Custom colours show a live WCAG contrast warning (text/panel, text/background, text/selection 4.5:1; secondary text and accent 3:1; button text on accent 4.5:1). Hidden for predefined palettes.
|
||||
- Message list shows skeleton rows while a folder loads; theme / style changes cross-fade for 0.3s (disabled with reduced motion).
|
||||
- Fixes: the default RustBox selection colour (`#28352e`) is treated as automatic so the light theme keeps a light selection; subjects take priority over snippets in message rows; flagged messages use a red side bar; reader-toolbar button labels collapse below 820px.
|
||||
- Message rows already show label chips (`message.tags`).
|
||||
- Calendar: optional second reminder per event (`reminder_extra_minutes`, V054; choices from "at start" to "1 week before"), default reminders for new events in Settings → Notifications (`calendar_settings.default_reminder_minutes` / `default_extra_reminder_minutes`), both exported as VALARMs. All-day events are reminded relative to the working-day start (default 09:00). `UpdateEvent` now distinguishes an absent reminder from `null`, so clearing a reminder works.
|
||||
- The interface-style selector is grouped with `<optgroup>`s (labels translated through the scoped `_text` map) and uses short names; `INTERFACE_SKINS` follows the same order.
|
||||
|
||||
## 0.2.24 filters
|
||||
|
||||
- **Apply now** (`POST /api/filters/match`, `src/routes/filters.rs`): the client sends the rule as a JSON tree (`{op: allof|anyof, args}`, `{not}`, `{test: {kind, match, value, header}}`, max depth 12 / 64 tests). The server translates it into two IMAP `UID SEARCH` criteria — an upper bound (superset) and a lower bound (certainly matching) — and runs them via the new `ImapClient::search_uids`. Header/subject `:contains` tests and sizes are exact; address tests (IMAP `FROM` also matches display names), `:is` / `:matches`, non-ASCII values and has-attachment are narrowed from the `messages` cache with three-valued evaluation; messages that still cannot be decided are counted as `uncertain` and not touched. At most 20 000 UIDs are returned.
|
||||
- The client (`91-sieve-apply.js`) applies, per message: flags (`\Seen`, IMAP keywords), labels (bulk tag endpoint), copies, then one move/Trash — via the existing message endpoints, 4 requests at a time with progress. Redirect, discard and set-flags are not applied to existing messages and are listed as skipped in the confirmation.
|
||||
- Entry points: *Check matches / Apply now* in the rule editor (re-check required after the rule changes), *Apply now* per rule in the rule list (rules using syntax the editor cannot rewrite are refused), the quick filter dialog option, and *Always move messages from this sender…* in the message context menu.
|
||||
- Editor additions: exceptions (`allof (<conditions>, not anyof (<exceptions>))`, parsed back), conditions `to-only`, `cc-only`, `size-over` / `size-under`, `has-attachment` (requires `mime`), `priority-high` (`X-Priority` 1/2 or `Importance: high`), actions `label` (`addflag "RustBox-<tag id>"`) and `trash`, templates, missing-folder creation on save. No schema change.
|
||||
- Not done: drag-and-drop reordering of rules (the ↑/↓ buttons remain).
|
||||
|
||||
## 0.2.23 interface styles
|
||||
|
||||
|
||||
+12
-1
@@ -32,7 +32,18 @@ V053 adds two appearance columns to `display_preferences`.
|
||||
|
||||
Fresh installations already contain both columns in `schema.sql` and are registered at the latest schema version. Existing V052 tenants execute `V053__skin_depth_and_gradients.sql`.
|
||||
|
||||
The next migration number is **V054**.
|
||||
## V054 — RustBox 0.2.24
|
||||
|
||||
V054 adds three appearance columns to `display_preferences` and calendar reminder columns:
|
||||
|
||||
- `theme_light_from` / `theme_dark_from`, defaults `07:00` / `19:00` (`HH:MM`, local time of the browser) — used when `theme = 'schedule'`: light between the two times, dark otherwise (the window may wrap past midnight);
|
||||
- `account_color`, default empty — an optional per-account colour shown as a stripe on the app bar and a dot next to the account address;
|
||||
- `calendar_events.reminder_extra_minutes` (nullable) — an optional second reminder per event, e.g. one day before;
|
||||
- `calendar_settings.default_reminder_minutes` / `default_extra_reminder_minutes` (nullable) — reminders pre-selected for new events.
|
||||
|
||||
Fresh installations already contain the columns in `schema.sql`. Existing V053 tenants execute `V054__theme_schedule_account_color_and_reminders.sql`.
|
||||
|
||||
The next migration number is **V055**.
|
||||
|
||||
## Migration rules
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE {{p}}display_preferences
|
||||
ADD COLUMN theme_light_from TEXT NOT NULL DEFAULT ('07:00'),
|
||||
ADD COLUMN theme_dark_from TEXT NOT NULL DEFAULT ('19:00'),
|
||||
ADD COLUMN account_color TEXT NOT NULL DEFAULT ('');
|
||||
|
||||
ALTER TABLE {{p}}calendar_events
|
||||
ADD COLUMN reminder_extra_minutes BIGINT;
|
||||
|
||||
ALTER TABLE {{p}}calendar_settings
|
||||
ADD COLUMN default_reminder_minutes BIGINT,
|
||||
ADD COLUMN default_extra_reminder_minutes BIGINT;
|
||||
@@ -1,7 +1,7 @@
|
||||
-- RustBox 0.2.0 MySQL schema.
|
||||
-- One physical database contains global sessions plus isolated per-account tables.
|
||||
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
|
||||
-- Squashed compatibility baseline: V052; current schema: V053.
|
||||
-- Squashed compatibility baseline: V052; current schema: V054.
|
||||
|
||||
-- RUSTBOX:GLOBAL-BEGIN
|
||||
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
||||
@@ -58,6 +58,7 @@ CREATE TABLE {{p}}calendar_events (
|
||||
meeting_url TEXT,
|
||||
color TEXT,
|
||||
reminder_minutes BIGINT,
|
||||
reminder_extra_minutes BIGINT,
|
||||
created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)),
|
||||
updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -76,6 +77,8 @@ CREATE TABLE {{p}}calendar_settings (
|
||||
caldav_username TEXT NOT NULL DEFAULT (''),
|
||||
caldav_password TEXT NOT NULL DEFAULT (''),
|
||||
caldav_enabled BIGINT NOT NULL DEFAULT 0,
|
||||
default_reminder_minutes BIGINT,
|
||||
default_extra_reminder_minutes BIGINT,
|
||||
updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -179,7 +182,10 @@ CREATE TABLE {{p}}display_preferences (
|
||||
preset_scale_unlocked BIGINT NOT NULL DEFAULT 0,
|
||||
adaptive_email_colors BIGINT NOT NULL DEFAULT 0,
|
||||
skin_depth TEXT NOT NULL DEFAULT ('standard'),
|
||||
skin_gradients BIGINT NOT NULL DEFAULT 0
|
||||
skin_gradients BIGINT NOT NULL DEFAULT 0,
|
||||
theme_light_from TEXT NOT NULL DEFAULT ('07:00'),
|
||||
theme_dark_from TEXT NOT NULL DEFAULT ('19:00'),
|
||||
account_color TEXT NOT NULL DEFAULT ('')
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE {{p}}draft_attachments (
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE {{p}}display_preferences
|
||||
ADD COLUMN theme_light_from TEXT NOT NULL DEFAULT '07:00',
|
||||
ADD COLUMN theme_dark_from TEXT NOT NULL DEFAULT '19:00',
|
||||
ADD COLUMN account_color TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE {{p}}calendar_events
|
||||
ADD COLUMN reminder_extra_minutes BIGINT;
|
||||
|
||||
ALTER TABLE {{p}}calendar_settings
|
||||
ADD COLUMN default_reminder_minutes BIGINT,
|
||||
ADD COLUMN default_extra_reminder_minutes BIGINT;
|
||||
@@ -1,7 +1,7 @@
|
||||
-- RustBox 0.2.0 PostgreSQL schema.
|
||||
-- One physical database contains global sessions plus isolated per-account tables.
|
||||
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
|
||||
-- Squashed compatibility baseline: V052; current schema: V053.
|
||||
-- Squashed compatibility baseline: V052; current schema: V054.
|
||||
|
||||
-- RUSTBOX:GLOBAL-BEGIN
|
||||
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
||||
@@ -58,6 +58,7 @@ CREATE TABLE {{p}}calendar_events (
|
||||
meeting_url TEXT,
|
||||
color TEXT,
|
||||
reminder_minutes BIGINT,
|
||||
reminder_extra_minutes BIGINT,
|
||||
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
|
||||
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
|
||||
);
|
||||
@@ -76,6 +77,8 @@ CREATE TABLE {{p}}calendar_settings (
|
||||
caldav_username TEXT NOT NULL DEFAULT '',
|
||||
caldav_password TEXT NOT NULL DEFAULT '',
|
||||
caldav_enabled BIGINT NOT NULL DEFAULT 0,
|
||||
default_reminder_minutes BIGINT,
|
||||
default_extra_reminder_minutes BIGINT,
|
||||
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
|
||||
);
|
||||
|
||||
@@ -179,7 +182,10 @@ CREATE TABLE {{p}}display_preferences (
|
||||
preset_scale_unlocked BIGINT NOT NULL DEFAULT 0,
|
||||
adaptive_email_colors BIGINT NOT NULL DEFAULT 0,
|
||||
skin_depth TEXT NOT NULL DEFAULT 'standard',
|
||||
skin_gradients BIGINT NOT NULL DEFAULT 0
|
||||
skin_gradients BIGINT NOT NULL DEFAULT 0,
|
||||
theme_light_from TEXT NOT NULL DEFAULT '07:00',
|
||||
theme_dark_from TEXT NOT NULL DEFAULT '19:00',
|
||||
account_color TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE {{p}}draft_attachments (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE {{p}}display_preferences
|
||||
ADD COLUMN theme_light_from TEXT NOT NULL DEFAULT '07:00';
|
||||
|
||||
ALTER TABLE {{p}}display_preferences
|
||||
ADD COLUMN theme_dark_from TEXT NOT NULL DEFAULT '19:00';
|
||||
|
||||
ALTER TABLE {{p}}display_preferences
|
||||
ADD COLUMN account_color TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE {{p}}calendar_events
|
||||
ADD COLUMN reminder_extra_minutes INTEGER;
|
||||
|
||||
ALTER TABLE {{p}}calendar_settings
|
||||
ADD COLUMN default_reminder_minutes INTEGER;
|
||||
|
||||
ALTER TABLE {{p}}calendar_settings
|
||||
ADD COLUMN default_extra_reminder_minutes INTEGER;
|
||||
@@ -1,7 +1,7 @@
|
||||
-- RustBox 0.2.0 SQLite schema.
|
||||
-- One physical database contains global sessions plus isolated per-account tables.
|
||||
-- Application tables are materialized from the tenant section by replacing {{p}}.
|
||||
-- Squashed compatibility baseline: V052; current schema: V053.
|
||||
-- Squashed compatibility baseline: V052; current schema: V054.
|
||||
|
||||
-- RUSTBOX:GLOBAL-BEGIN
|
||||
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
||||
@@ -58,6 +58,7 @@ CREATE TABLE {{p}}calendar_events (
|
||||
meeting_url TEXT,
|
||||
color TEXT,
|
||||
reminder_minutes INTEGER,
|
||||
reminder_extra_minutes INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -76,6 +77,8 @@ CREATE TABLE {{p}}calendar_settings (
|
||||
caldav_username TEXT NOT NULL DEFAULT '',
|
||||
caldav_password TEXT NOT NULL DEFAULT '',
|
||||
caldav_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
default_reminder_minutes INTEGER,
|
||||
default_extra_reminder_minutes INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -122,7 +125,7 @@ CREATE TABLE {{p}}display_preferences (
|
||||
theme TEXT NOT NULL DEFAULT 'system',
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
compose_format TEXT NOT NULL DEFAULT 'html', deep_index BOOLEAN NOT NULL DEFAULT 0, animation_mode TEXT NULL, preview_pane TEXT NOT NULL DEFAULT 'right', ui_scale TEXT NOT NULL DEFAULT 'normal', color_preset TEXT NOT NULL DEFAULT 'sage', accent_color TEXT NOT NULL DEFAULT '#6f9c86', font_family TEXT NOT NULL DEFAULT 'system', font_size INTEGER NOT NULL DEFAULT 13, row_height INTEGER NOT NULL DEFAULT 58, folder_pane_width INTEGER NOT NULL DEFAULT 228, message_pane_width INTEGER NOT NULL DEFAULT 420, border_radius INTEGER NOT NULL DEFAULT 4, contrast TEXT NOT NULL DEFAULT 'normal', show_avatars INTEGER NOT NULL DEFAULT 1, show_attachment_icons INTEGER NOT NULL DEFAULT 1, show_flags INTEGER NOT NULL DEFAULT 1, reduce_motion INTEGER NOT NULL DEFAULT 0, date_format TEXT NOT NULL DEFAULT 'locale', time_format TEXT NOT NULL DEFAULT 'locale', background_color TEXT NOT NULL DEFAULT '#121513', panel_color TEXT NOT NULL DEFAULT '#181c19', text_color TEXT NOT NULL DEFAULT '#edf0ec', compose_autosave_ms INTEGER NOT NULL DEFAULT 3000, default_priority TEXT NOT NULL DEFAULT 'normal', request_mdn_default INTEGER NOT NULL DEFAULT 0, attachment_reminder INTEGER NOT NULL DEFAULT 1, icon_size INTEGER NOT NULL DEFAULT 18, toolbar_density TEXT NOT NULL DEFAULT 'normal', sidebar_item_height INTEGER NOT NULL DEFAULT 29, reader_max_width INTEGER NOT NULL DEFAULT 0, composer_width INTEGER NOT NULL DEFAULT 900, selection_color TEXT NOT NULL DEFAULT '#28352e', unread_font_weight INTEGER NOT NULL DEFAULT 730, unread_badge_style TEXT NOT NULL DEFAULT 'dot', compact_headers INTEGER NOT NULL DEFAULT 0, show_borders INTEGER NOT NULL DEFAULT 1, scrollbar_density TEXT NOT NULL DEFAULT 'thin', monochrome INTEGER NOT NULL DEFAULT 0, folder_background TEXT NOT NULL DEFAULT '', message_background TEXT NOT NULL DEFAULT '', reader_background TEXT NOT NULL DEFAULT '', compose_font_family TEXT NOT NULL DEFAULT 'system', reply_position TEXT NOT NULL DEFAULT 'above', quote_style TEXT NOT NULL DEFAULT 'standard', forward_mode TEXT NOT NULL DEFAULT 'inline', spellcheck_enabled INTEGER NOT NULL DEFAULT 1, interface_skin TEXT NOT NULL DEFAULT 'rustbox', preset_scale_unlocked INTEGER NOT NULL DEFAULT 0, adaptive_email_colors INTEGER NOT NULL DEFAULT 0, skin_depth TEXT NOT NULL DEFAULT 'standard', skin_gradients INTEGER NOT NULL DEFAULT 0);
|
||||
compose_format TEXT NOT NULL DEFAULT 'html', deep_index BOOLEAN NOT NULL DEFAULT 0, animation_mode TEXT NULL, preview_pane TEXT NOT NULL DEFAULT 'right', ui_scale TEXT NOT NULL DEFAULT 'normal', color_preset TEXT NOT NULL DEFAULT 'sage', accent_color TEXT NOT NULL DEFAULT '#6f9c86', font_family TEXT NOT NULL DEFAULT 'system', font_size INTEGER NOT NULL DEFAULT 13, row_height INTEGER NOT NULL DEFAULT 58, folder_pane_width INTEGER NOT NULL DEFAULT 228, message_pane_width INTEGER NOT NULL DEFAULT 420, border_radius INTEGER NOT NULL DEFAULT 4, contrast TEXT NOT NULL DEFAULT 'normal', show_avatars INTEGER NOT NULL DEFAULT 1, show_attachment_icons INTEGER NOT NULL DEFAULT 1, show_flags INTEGER NOT NULL DEFAULT 1, reduce_motion INTEGER NOT NULL DEFAULT 0, date_format TEXT NOT NULL DEFAULT 'locale', time_format TEXT NOT NULL DEFAULT 'locale', background_color TEXT NOT NULL DEFAULT '#121513', panel_color TEXT NOT NULL DEFAULT '#181c19', text_color TEXT NOT NULL DEFAULT '#edf0ec', compose_autosave_ms INTEGER NOT NULL DEFAULT 3000, default_priority TEXT NOT NULL DEFAULT 'normal', request_mdn_default INTEGER NOT NULL DEFAULT 0, attachment_reminder INTEGER NOT NULL DEFAULT 1, icon_size INTEGER NOT NULL DEFAULT 18, toolbar_density TEXT NOT NULL DEFAULT 'normal', sidebar_item_height INTEGER NOT NULL DEFAULT 29, reader_max_width INTEGER NOT NULL DEFAULT 0, composer_width INTEGER NOT NULL DEFAULT 900, selection_color TEXT NOT NULL DEFAULT '#28352e', unread_font_weight INTEGER NOT NULL DEFAULT 730, unread_badge_style TEXT NOT NULL DEFAULT 'dot', compact_headers INTEGER NOT NULL DEFAULT 0, show_borders INTEGER NOT NULL DEFAULT 1, scrollbar_density TEXT NOT NULL DEFAULT 'thin', monochrome INTEGER NOT NULL DEFAULT 0, folder_background TEXT NOT NULL DEFAULT '', message_background TEXT NOT NULL DEFAULT '', reader_background TEXT NOT NULL DEFAULT '', compose_font_family TEXT NOT NULL DEFAULT 'system', reply_position TEXT NOT NULL DEFAULT 'above', quote_style TEXT NOT NULL DEFAULT 'standard', forward_mode TEXT NOT NULL DEFAULT 'inline', spellcheck_enabled INTEGER NOT NULL DEFAULT 1, interface_skin TEXT NOT NULL DEFAULT 'rustbox', preset_scale_unlocked INTEGER NOT NULL DEFAULT 0, adaptive_email_colors INTEGER NOT NULL DEFAULT 0, skin_depth TEXT NOT NULL DEFAULT 'standard', skin_gradients INTEGER NOT NULL DEFAULT 0, theme_light_from TEXT NOT NULL DEFAULT '07:00', theme_dark_from TEXT NOT NULL DEFAULT '19:00', account_color TEXT NOT NULL DEFAULT '');
|
||||
|
||||
CREATE TABLE {{p}}draft_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
Regular → Executable
+71
@@ -999,6 +999,18 @@ require(
|
||||
and 'savePreference("skinGradients"' in bindings_js,
|
||||
"style gradients have their own persisted Appearance switch",
|
||||
)
|
||||
gradient_css = read("src/web/css/59-skin-gradients.css")
|
||||
require(
|
||||
"59-skin-gradients.css" in css_manifest
|
||||
and css_manifest.index("58-skin-immersive.css") < css_manifest.index("59-skin-gradients.css") < css_manifest.index("60-responsive.css"),
|
||||
"style gradient layer loads after the immersive rules",
|
||||
)
|
||||
require(
|
||||
all(f"var(--grad-{layer})" in gradient_css for layer in ("backdrop", "appbar", "toolbar", "sidebar", "selected", "primary"))
|
||||
and ':root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .appbar' in gradient_css
|
||||
and "data-skin-depth" not in gradient_css.split("/* Consumers", 1)[-1].split("{", 1)[0],
|
||||
"style gradients apply to every predefined style except RustBox at any style depth",
|
||||
)
|
||||
# Every gradient in the stylesheets must sit in a rule scoped to the switch.
|
||||
ungated_gradients = []
|
||||
for css_name in css_manifest:
|
||||
@@ -1010,6 +1022,65 @@ for css_name in css_manifest:
|
||||
if 'data-skin-gradients="true"' not in selector:
|
||||
ungated_gradients.append(f"{css_name}: {selector.strip()[:60]}")
|
||||
require(not ungated_gradients, "every stylesheet gradient is gated by the style-gradients switch" + (f" (ungated: {'; '.join(ungated_gradients[:5])})" if ungated_gradients else ""))
|
||||
for _, dialect in MIGRATION_DIALECTS:
|
||||
require(all(column in read(f"migrations/{dialect}/schema.sql") and column in read(f"src/queries/{dialect}/queries.rs") for column in ("theme_light_from", "theme_dark_from", "account_color")), f"{dialect} fresh schema and display query include the theme schedule and account colour")
|
||||
try:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.executescript("CREATE TABLE u_prod__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system'); INSERT INTO u_prod__display_preferences(id) VALUES(1); CREATE TABLE u_prod__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER); INSERT INTO u_prod__calendar_events(id, reminder_minutes) VALUES('e1', 15); CREATE TABLE u_prod__calendar_settings (id INTEGER PRIMARY KEY); INSERT INTO u_prod__calendar_settings(id) VALUES(1);")
|
||||
conn.executescript(read("migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql").replace("{{p}}", "u_prod__"))
|
||||
require(conn.execute("SELECT theme_light_from, theme_dark_from, account_color FROM u_prod__display_preferences WHERE id=1").fetchone() == ("07:00", "19:00", ""), "SQLite V054 adds the theme schedule and account colour with defaults")
|
||||
require(conn.execute("SELECT reminder_minutes, reminder_extra_minutes FROM u_prod__calendar_events WHERE id='e1'").fetchone() == (15, None), "SQLite V054 adds an optional second calendar reminder and keeps existing reminders")
|
||||
require(conn.execute("SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_prod__calendar_settings WHERE id=1").fetchone() == (None, None), "SQLite V054 adds empty default reminders for new events")
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
errors.append(f"SQLite V054 migration execution failed: {exc}")
|
||||
require(
|
||||
'"schedule"' in display_prefs_rs and 'value="schedule"' in index_html and "function scheduledTheme(" in prefs_js
|
||||
and 'id="pref-account-color"' in index_html and 'html[data-account-color="true"] .appbar::before' in read("src/web/css/50-settings.css"),
|
||||
"time-of-day theme and per-account colour are wired end to end",
|
||||
)
|
||||
for _, dialect in MIGRATION_DIALECTS:
|
||||
queries_text = read(f"src/queries/{dialect}/queries.rs")
|
||||
require(
|
||||
"reminder_extra_minutes" in read(f"migrations/{dialect}/schema.sql") and "reminder_extra_minutes" in queries_text
|
||||
and "default_extra_reminder_minutes" in queries_text,
|
||||
f"{dialect} schema and calendar queries include the second reminder and reminder defaults",
|
||||
)
|
||||
calendar_js = read("src/web/js/71-calendar.js")
|
||||
require(
|
||||
'id="calendar-reminder-extra"' in index_html and 'id="calendar-default-reminder"' in index_html
|
||||
and "reminder_extra_minutes: reminderSelectValue(" in calendar_js
|
||||
and "calendarReminderTrigger(event, minutes)" in read("src/web/js/80-settings-realtime.js"),
|
||||
"calendar events support a second reminder (e.g. one day before) with defaults for new events",
|
||||
)
|
||||
require("function updateContrastWarning(" in prefs_js and 'id="pref-contrast-warning"' in index_html, "custom colours show a live contrast warning")
|
||||
sieve_js = read("src/web/js/90-sieve.js")
|
||||
sieve_apply_js = read("src/web/js/91-sieve-apply.js")
|
||||
filters_rs = read("src/routes/filters.rs")
|
||||
require(
|
||||
'.route("/filters/match", post(filters::match_filter_handler))' in read("src/routes/mod.rs")
|
||||
and "async fn search_uids(" in read("src/imap/client.rs")
|
||||
and "async fn search_uids(" in read("src/imap/mock.rs")
|
||||
and all("pub mod db_filter_match" in read(f"src/queries/{d}/queries.rs") for d in ("sqlite", "mysql", "pgsql")),
|
||||
"filters: POST /filters/match is routed and backed by IMAP UID SEARCH on every client",
|
||||
)
|
||||
require(
|
||||
"fn bounds(" in filters_rs and "fn resolve_matches(" in filters_rs and "uncertain" in filters_rs and "#[cfg(test)]" in filters_rs,
|
||||
"filters: apply-now matching uses upper/lower IMAP bounds, decides the gap from the cache and reports uncertain messages",
|
||||
)
|
||||
require(
|
||||
"91-sieve-apply.js" in read("src/web/js/manifest.txt")
|
||||
and 'id="sieve-apply-check"' in index_html and 'id="sieve-apply-run"' in index_html
|
||||
and "function runParsedSieveRuleNow(" in sieve_apply_js and "function alwaysMoveFromSender(" in sieve_apply_js
|
||||
and "function ensureSieveTargetFolders(" in sieve_apply_js and 'id="sieve-templates"' in index_html,
|
||||
"filters: apply now, per-rule run now, templates, missing-folder creation and 'always move from this sender' are wired",
|
||||
)
|
||||
require(
|
||||
'id="sieve-builder-exceptions"' in index_html and "function splitSieveExceptions(" in sieve_js
|
||||
and all(f'"{kind}"' in sieve_js for kind in ("to-only", "cc-only", "size-over", "size-under", "has-attachment", "priority-high"))
|
||||
and '"label"' in sieve_js and '"trash"' in sieve_js,
|
||||
"filters: exceptions, new conditions (To/Cc only, size, attachment, priority) and label/trash actions round-trip through the editor",
|
||||
)
|
||||
mysql_text_literal_defaults = re.findall(r"^\s*(\w+)\s+TEXT(?:\s+NOT NULL)?\s+DEFAULT\s+'", read("migrations/mysql/schema.sql") + "\n" + "\n".join(read(f"migrations/mysql/{name}") for _, name in MIGRATIONS["mysql"]), re.M)
|
||||
require(not mysql_text_literal_defaults, "MySQL TEXT columns use expression defaults" + (f" (literal: {', '.join(mysql_text_literal_defaults)})" if mysql_text_literal_defaults else ""))
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
|
||||
meeting_url: url,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
reminder_extra_minutes: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+110
-6
@@ -1,6 +1,6 @@
|
||||
use crate::db::pool::{Connection, DbValue, ToDbValue, params};
|
||||
use crate::queries::sqlite::queries::db_calendar::{EVENT_SELECT_COLS, SETTINGS_SELECT_COLS};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -23,6 +23,8 @@ pub struct CalendarEvent {
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
/// Optional second reminder (e.g. one day before), in minutes before start.
|
||||
pub reminder_extra_minutes: Option<i64>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -53,6 +55,16 @@ pub struct CreateEvent {
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub reminder_extra_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
/// `Some(None)` when the field is present and `null` (clear it), `None` when absent.
|
||||
fn deserialize_nullable_minutes<'de, D>(deserializer: D) -> Result<Option<Option<i64>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(Some(Option::<i64>::deserialize(deserializer)?))
|
||||
}
|
||||
|
||||
fn default_attendees() -> String {
|
||||
@@ -81,7 +93,10 @@ pub struct UpdateEvent {
|
||||
pub status: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
|
||||
pub reminder_minutes: Option<Option<i64>>,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
|
||||
pub reminder_extra_minutes: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -98,6 +113,9 @@ pub struct CalendarSettings {
|
||||
pub caldav_username: String,
|
||||
pub caldav_enabled: bool,
|
||||
pub updated_at: String,
|
||||
/// Reminders pre-selected for new events.
|
||||
pub default_reminder_minutes: Option<i64>,
|
||||
pub default_extra_reminder_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -114,6 +132,10 @@ pub struct UpdateCalendarSettings {
|
||||
pub caldav_username: Option<String>,
|
||||
pub caldav_password: Option<String>,
|
||||
pub caldav_enabled: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
|
||||
pub default_reminder_minutes: Option<Option<i64>>,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
|
||||
pub default_extra_reminder_minutes: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -149,6 +171,7 @@ fn row_to_event(row: &crate::db::pool::Row<'_>) -> Result<CalendarEvent, crate::
|
||||
reminder_minutes: row.get(17)?,
|
||||
created_at: row.get(18)?,
|
||||
updated_at: row.get(19)?,
|
||||
reminder_extra_minutes: row.get(20)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +192,8 @@ fn row_to_settings(
|
||||
caldav_username: row.get(9)?,
|
||||
caldav_enabled: caldav_enabled_int != 0,
|
||||
updated_at: row.get(11)?,
|
||||
default_reminder_minutes: row.get(12)?,
|
||||
default_extra_reminder_minutes: row.get(13)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,6 +294,14 @@ pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEve
|
||||
)
|
||||
.map_err(|e| format!("Failed to create event: {e}"))?;
|
||||
|
||||
if let Some(minutes) = data.reminder_extra_minutes {
|
||||
conn.execute(
|
||||
"UPDATE calendar_events SET reminder_extra_minutes = ?1 WHERE id = ?2",
|
||||
params![minutes, id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set second reminder: {e}"))?;
|
||||
}
|
||||
|
||||
get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string())
|
||||
}
|
||||
|
||||
@@ -309,10 +342,15 @@ pub fn update_event(
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if let Some(v) = data.reminder_minutes {
|
||||
sets.push(format!("reminder_minutes = ?{idx}"));
|
||||
values.push(v.to_db_value());
|
||||
idx += 1;
|
||||
for (column, value) in [
|
||||
("reminder_minutes", data.reminder_minutes),
|
||||
("reminder_extra_minutes", data.reminder_extra_minutes),
|
||||
] {
|
||||
if let Some(minutes) = value {
|
||||
sets.push(format!("{column} = ?{idx}"));
|
||||
values.push(minutes.to_db_value());
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if sets.is_empty() {
|
||||
@@ -409,6 +447,20 @@ pub fn update_calendar_settings(
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
for (column, value) in [
|
||||
("default_reminder_minutes", data.default_reminder_minutes),
|
||||
(
|
||||
"default_extra_reminder_minutes",
|
||||
data.default_extra_reminder_minutes,
|
||||
),
|
||||
] {
|
||||
if let Some(minutes) = value {
|
||||
sets.push(format!("{column} = ?{idx}"));
|
||||
values.push(minutes.to_db_value());
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = data.caldav_enabled {
|
||||
sets.push(format!("caldav_enabled = ?{idx}"));
|
||||
values.push((v as i32).to_db_value());
|
||||
@@ -497,6 +549,7 @@ mod tests {
|
||||
meeting_url: Some("https://zoom.us/j/123".to_string()),
|
||||
color: Some("blue".to_string()),
|
||||
reminder_minutes: Some(15),
|
||||
reminder_extra_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
@@ -531,6 +584,7 @@ mod tests {
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
reminder_extra_minutes: None,
|
||||
};
|
||||
|
||||
let late = CreateEvent {
|
||||
@@ -572,6 +626,7 @@ mod tests {
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
reminder_extra_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
@@ -589,6 +644,7 @@ mod tests {
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
reminder_extra_minutes: None,
|
||||
};
|
||||
|
||||
let updated = update_event(&conn, &event.id, &update).unwrap();
|
||||
@@ -617,6 +673,7 @@ mod tests {
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
reminder_extra_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
@@ -646,6 +703,7 @@ mod tests {
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
reminder_extra_minutes: None,
|
||||
};
|
||||
|
||||
create_event(&conn, &data).unwrap();
|
||||
@@ -680,12 +738,57 @@ mod tests {
|
||||
caldav_username: None,
|
||||
caldav_password: None,
|
||||
caldav_enabled: None,
|
||||
default_reminder_minutes: Some(Some(15)),
|
||||
default_extra_reminder_minutes: Some(Some(1440)),
|
||||
};
|
||||
|
||||
let updated = update_calendar_settings(&conn, &update).unwrap();
|
||||
assert_eq!(updated.default_view, "week");
|
||||
assert_eq!(updated.week_starts_on, 1);
|
||||
assert_eq!(updated.time_format, "24h");
|
||||
assert_eq!(updated.default_reminder_minutes, Some(15));
|
||||
assert_eq!(updated.default_extra_reminder_minutes, Some(1440));
|
||||
|
||||
let clear: UpdateCalendarSettings =
|
||||
serde_json::from_value(serde_json::json!({"default_extra_reminder_minutes": null}))
|
||||
.unwrap();
|
||||
let cleared = update_calendar_settings(&conn, &clear).unwrap();
|
||||
assert_eq!(cleared.default_reminder_minutes, Some(15));
|
||||
assert_eq!(cleared.default_extra_reminder_minutes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_reminder_roundtrips_and_reminders_can_be_cleared() {
|
||||
let conn = open_test_db();
|
||||
let mut data = sample_reminder_event();
|
||||
data.reminder_minutes = Some(15);
|
||||
data.reminder_extra_minutes = Some(1440);
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
assert_eq!(event.reminder_minutes, Some(15));
|
||||
assert_eq!(event.reminder_extra_minutes, Some(1440));
|
||||
|
||||
let update: UpdateEvent = serde_json::from_value(serde_json::json!({
|
||||
"reminder_minutes": null,
|
||||
"reminder_extra_minutes": 2880
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = update_event(&conn, &event.id, &update).unwrap().unwrap();
|
||||
assert_eq!(updated.reminder_minutes, None);
|
||||
assert_eq!(updated.reminder_extra_minutes, Some(2880));
|
||||
|
||||
let untouched: UpdateEvent =
|
||||
serde_json::from_value(serde_json::json!({"title": "Renamed"})).unwrap();
|
||||
let renamed = update_event(&conn, &event.id, &untouched).unwrap().unwrap();
|
||||
assert_eq!(renamed.reminder_extra_minutes, Some(2880));
|
||||
}
|
||||
|
||||
fn sample_reminder_event() -> CreateEvent {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"title": "Dentist",
|
||||
"start_time": "2026-11-10T09:00:00Z",
|
||||
"end_time": "2026-11-10T10:00:00Z"
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -726,6 +829,7 @@ mod tests {
|
||||
meeting_url: self.meeting_url.clone(),
|
||||
color: self.color.clone(),
|
||||
reminder_minutes: self.reminder_minutes,
|
||||
reminder_extra_minutes: self.reminder_extra_minutes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
-13
@@ -7,28 +7,30 @@ use serde::{Deserialize, Deserializer, Serialize};
|
||||
/// the server default and the per-user preference can never drift apart.
|
||||
pub const INTERFACE_SKINS: &[&str] = &[
|
||||
"rustbox",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"clean",
|
||||
"gmail",
|
||||
"proton",
|
||||
"zoho",
|
||||
"outlook",
|
||||
"minimal",
|
||||
"graphite",
|
||||
"high-contrast",
|
||||
"fastmail",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"gmail",
|
||||
"outlook",
|
||||
"apple-mail",
|
||||
"fastmail",
|
||||
"proton",
|
||||
"zoho",
|
||||
"yahoo",
|
||||
"hey",
|
||||
"nord",
|
||||
"github",
|
||||
"gitlab",
|
||||
"discord",
|
||||
"slack",
|
||||
"reddit",
|
||||
"notion",
|
||||
"linear",
|
||||
"material",
|
||||
"nord",
|
||||
"dracula",
|
||||
"catppuccin",
|
||||
"solarized",
|
||||
@@ -40,9 +42,13 @@ pub const INTERFACE_SKINS: &[&str] = &[
|
||||
"paper",
|
||||
"terminal",
|
||||
"aqua",
|
||||
"linear",
|
||||
"material",
|
||||
"frutiger-aero",
|
||||
"retro",
|
||||
"winxp",
|
||||
"osx",
|
||||
"ios6",
|
||||
"win7",
|
||||
"beos",
|
||||
];
|
||||
|
||||
/// How far a predefined interface style may reshape the interface.
|
||||
@@ -105,6 +111,9 @@ pub struct DisplayPreferences {
|
||||
pub adaptive_email_colors: bool,
|
||||
pub skin_depth: String,
|
||||
pub skin_gradients: bool,
|
||||
pub theme_light_from: String,
|
||||
pub theme_dark_from: String,
|
||||
pub account_color: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -166,6 +175,9 @@ pub struct UpdateDisplayPreferences {
|
||||
pub adaptive_email_colors: Option<bool>,
|
||||
pub skin_depth: Option<String>,
|
||||
pub skin_gradients: Option<bool>,
|
||||
pub theme_light_from: Option<String>,
|
||||
pub theme_dark_from: Option<String>,
|
||||
pub account_color: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_animation_mode_field<'de, D>(
|
||||
@@ -235,6 +247,9 @@ fn defaults() -> DisplayPreferences {
|
||||
adaptive_email_colors: false,
|
||||
skin_depth: "standard".into(),
|
||||
skin_gradients: false,
|
||||
theme_light_from: "07:00".into(),
|
||||
theme_dark_from: "19:00".into(),
|
||||
account_color: String::new(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -297,7 +312,10 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
|
||||
adaptive_email_colors: row.get::<_, i32>(52)? != 0,
|
||||
skin_depth: row.get(53)?,
|
||||
skin_gradients: row.get::<_, i32>(54)? != 0,
|
||||
updated_at: row.get(55)?,
|
||||
theme_light_from: row.get(55)?,
|
||||
theme_dark_from: row.get(56)?,
|
||||
account_color: row.get(57)?,
|
||||
updated_at: row.get(58)?,
|
||||
})
|
||||
});
|
||||
match result {
|
||||
@@ -321,6 +339,25 @@ fn range(name: &str, value: i32, min: i32, max: i32) -> Result<(), String> {
|
||||
Err(format!("Invalid {name}: {value}"))
|
||||
}
|
||||
}
|
||||
/// `HH:MM`, 24-hour clock.
|
||||
fn valid_clock_time(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() != 5 || bytes[2] != b':' {
|
||||
return false;
|
||||
}
|
||||
let digits = |a: u8, b: u8| -> Option<u8> {
|
||||
if a.is_ascii_digit() && b.is_ascii_digit() {
|
||||
Some((a - b'0') * 10 + (b - b'0'))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
matches!(
|
||||
(digits(bytes[0], bytes[1]), digits(bytes[3], bytes[4])),
|
||||
(Some(hour), Some(minute)) if hour < 24 && minute < 60
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_hex(value: &str) -> bool {
|
||||
value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
@@ -367,7 +404,7 @@ pub fn update_preferences(
|
||||
enum_value("density", v, &["compact", "comfortable"])?;
|
||||
}
|
||||
if let Some(ref v) = data.theme {
|
||||
enum_value("theme", v, &["light", "dark", "system"])?;
|
||||
enum_value("theme", v, &["light", "dark", "system", "schedule"])?;
|
||||
}
|
||||
if let Some(ref v) = data.compose_format {
|
||||
enum_value("compose_format", v, &["html", "text", "markdown"])?;
|
||||
@@ -443,6 +480,22 @@ pub fn update_preferences(
|
||||
if let Some(ref v) = data.skin_depth {
|
||||
enum_value("skin_depth", v, SKIN_DEPTHS)?;
|
||||
}
|
||||
for (name, value) in [
|
||||
("theme_light_from", data.theme_light_from.as_deref()),
|
||||
("theme_dark_from", data.theme_dark_from.as_deref()),
|
||||
] {
|
||||
if let Some(v) = value
|
||||
&& !valid_clock_time(v)
|
||||
{
|
||||
return Err(format!("Invalid {name}: {v}"));
|
||||
}
|
||||
}
|
||||
if let Some(ref v) = data.account_color
|
||||
&& !v.is_empty()
|
||||
&& !valid_hex(v)
|
||||
{
|
||||
return Err(format!("Invalid account_color: {v}"));
|
||||
}
|
||||
if let Some(v) = data.font_size {
|
||||
range("font_size", v, 11, 18)?;
|
||||
}
|
||||
@@ -577,6 +630,9 @@ pub fn update_preferences(
|
||||
set_bool!(adaptive_email_colors, "adaptive_email_colors");
|
||||
set_text!(skin_depth, "skin_depth");
|
||||
set_bool!(skin_gradients, "skin_gradients");
|
||||
set_text!(theme_light_from, "theme_light_from");
|
||||
set_text!(theme_dark_from, "theme_dark_from");
|
||||
set_text!(account_color, "account_color");
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_preferences(conn);
|
||||
@@ -692,6 +748,43 @@ mod tests {
|
||||
assert!(update_preferences(&conn, &update).unwrap().skin_gradients);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_schedule_and_account_color_roundtrip() {
|
||||
let conn = open_test_db();
|
||||
let defaults = get_preferences(&conn).unwrap();
|
||||
assert_eq!(defaults.theme_light_from, "07:00");
|
||||
assert_eq!(defaults.theme_dark_from, "19:00");
|
||||
assert_eq!(defaults.account_color, "");
|
||||
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
|
||||
"theme": "schedule", "theme_light_from": "06:30", "theme_dark_from": "20:15", "account_color": "#cc3366"
|
||||
}))
|
||||
.unwrap();
|
||||
let prefs = update_preferences(&conn, &update).unwrap();
|
||||
assert_eq!(prefs.theme, "schedule");
|
||||
assert_eq!(prefs.theme_light_from, "06:30");
|
||||
assert_eq!(prefs.theme_dark_from, "20:15");
|
||||
assert_eq!(prefs.account_color, "#cc3366");
|
||||
let clear: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"account_color": ""})).unwrap();
|
||||
assert_eq!(update_preferences(&conn, &clear).unwrap().account_color, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_theme_schedule_times_are_rejected() {
|
||||
let conn = open_test_db();
|
||||
for value in ["7:00", "24:00", "12:60", "ab:cd", "12-30", ""] {
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"theme_light_from": value})).unwrap();
|
||||
assert!(
|
||||
update_preferences(&conn, &update).is_err(),
|
||||
"{value} accepted"
|
||||
);
|
||||
}
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"account_color": "red"})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_skin_depth_is_rejected() {
|
||||
let conn = open_test_db();
|
||||
|
||||
+64
-13
@@ -21,18 +21,38 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
|
||||
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
|
||||
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
|
||||
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
53,
|
||||
include_str!("../../migrations/sqlite/V053__skin_depth_and_gradients.sql"),
|
||||
)];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
53,
|
||||
include_str!("../../migrations/mysql/V053__skin_depth_and_gradients.sql"),
|
||||
)];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
53,
|
||||
include_str!("../../migrations/pgsql/V053__skin_depth_and_gradients.sql"),
|
||||
)];
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
53,
|
||||
include_str!("../../migrations/sqlite/V053__skin_depth_and_gradients.sql"),
|
||||
),
|
||||
(
|
||||
54,
|
||||
include_str!(
|
||||
"../../migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql"
|
||||
),
|
||||
),
|
||||
];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
53,
|
||||
include_str!("../../migrations/mysql/V053__skin_depth_and_gradients.sql"),
|
||||
),
|
||||
(
|
||||
54,
|
||||
include_str!("../../migrations/mysql/V054__theme_schedule_account_color_and_reminders.sql"),
|
||||
),
|
||||
];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
53,
|
||||
include_str!("../../migrations/pgsql/V053__skin_depth_and_gradients.sql"),
|
||||
),
|
||||
(
|
||||
54,
|
||||
include_str!("../../migrations/pgsql/V054__theme_schedule_account_color_and_reminders.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
const TENANT_TABLES: &[&str] = &[
|
||||
"blob_delete_outbox",
|
||||
@@ -2632,8 +2652,12 @@ mod tests {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE rustbox_accounts (user_hash TEXT PRIMARY KEY, table_prefix TEXT NOT NULL, schema_version INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\n\
|
||||
CREATE TABLE u_test__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system', interface_skin TEXT NOT NULL DEFAULT 'rustbox');\n\
|
||||
CREATE TABLE u_test__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER);\n\
|
||||
CREATE TABLE u_test__calendar_settings (id INTEGER PRIMARY KEY);\n\
|
||||
INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES('test', 'u_test__', 52);\n\
|
||||
INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');",
|
||||
INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');\n\
|
||||
INSERT INTO u_test__calendar_events(id, reminder_minutes) VALUES('e1', 15);\n\
|
||||
INSERT INTO u_test__calendar_settings(id) VALUES(1);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -2658,6 +2682,33 @@ mod tests {
|
||||
assert_eq!(skin, "nord");
|
||||
assert_eq!(depth, "standard");
|
||||
assert_eq!(gradients, 0);
|
||||
let (light_from, dark_from, account_color): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT theme_light_from, theme_dark_from, account_color FROM u_test__display_preferences WHERE id=1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(light_from, "07:00");
|
||||
assert_eq!(dark_from, "19:00");
|
||||
assert_eq!(account_color, "");
|
||||
let (reminder, extra): (i64, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT reminder_minutes, reminder_extra_minutes FROM u_test__calendar_events WHERE id='e1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reminder, 15);
|
||||
assert_eq!(extra, None);
|
||||
let (default_reminder, default_extra): (Option<i64>, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_test__calendar_settings WHERE id=1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((default_reminder, default_extra), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -218,6 +218,15 @@ pub trait ImapClient: Send + Sync {
|
||||
folder: &str,
|
||||
) -> Result<Vec<(u32, Vec<String>)>, ImapError>;
|
||||
|
||||
/// `UID SEARCH <criteria>` in `folder`. Callers must build `criteria`
|
||||
/// from validated, quoted parts (see `routes::filters`).
|
||||
async fn search_uids(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
criteria: &str,
|
||||
) -> Result<Vec<u32>, ImapError>;
|
||||
|
||||
async fn folder_status_extended(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -1156,6 +1165,31 @@ impl ImapClient for RealImapClient {
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn search_uids(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
criteria: &str,
|
||||
) -> Result<Vec<u32>, ImapError> {
|
||||
let mut session = self.pool.checkout(creds).await?;
|
||||
session.select(folder).await.map_err(|e| match &e {
|
||||
async_imap::error::Error::No(msg)
|
||||
if msg.to_lowercase().contains("not found")
|
||||
|| msg.to_lowercase().contains("doesn't exist")
|
||||
|| msg.to_lowercase().contains("does not exist")
|
||||
|| msg.to_lowercase().contains("no such") =>
|
||||
{
|
||||
ImapError::FolderNotFound(folder.to_string())
|
||||
}
|
||||
_ => map_imap_error(e),
|
||||
})?;
|
||||
let matches = session.uid_search(criteria).await.map_err(map_imap_error)?;
|
||||
session.recycle();
|
||||
let mut uids: Vec<u32> = matches.into_iter().collect();
|
||||
uids.sort_unstable();
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
async fn folder_status_extended(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
|
||||
@@ -383,6 +383,26 @@ impl ImapClient for MockImapClient {
|
||||
Ok(headers.iter().map(|h| (h.uid, h.flags.clone())).collect())
|
||||
}
|
||||
|
||||
/// Test double: `NOT ALL` matches nothing, any other criteria every
|
||||
/// header the mock knows about (a superset, which callers post-filter).
|
||||
async fn search_uids(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
criteria: &str,
|
||||
) -> Result<Vec<u32>, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
if criteria.trim() == "NOT ALL" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let headers = self.headers.lock().unwrap();
|
||||
let mut uids: Vec<u32> = headers.iter().map(|h| h.uid).collect();
|
||||
uids.sort_unstable();
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
async fn folder_status_extended(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
|
||||
@@ -6,10 +6,11 @@ pub mod db_calendar {
|
||||
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
|
||||
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
|
||||
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
|
||||
created_at, updated_at";
|
||||
created_at, updated_at, reminder_extra_minutes";
|
||||
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
|
||||
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
|
||||
caldav_username, caldav_enabled, updated_at";
|
||||
caldav_username, caldav_enabled, updated_at, default_reminder_minutes, \
|
||||
default_extra_reminder_minutes";
|
||||
|
||||
pub const Q_001: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)";
|
||||
pub const Q_002: &str = "INSERT INTO calendar_events
|
||||
@@ -127,7 +128,7 @@ pub mod db_contacts {
|
||||
}
|
||||
|
||||
pub mod db_display_preferences {
|
||||
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, updated_at FROM display_preferences WHERE id = 1";
|
||||
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, theme_light_from, theme_dark_from, account_color, updated_at FROM display_preferences WHERE id = 1";
|
||||
pub const Q_002: &str = "INSERT IGNORE INTO display_preferences (id) VALUES (1)";
|
||||
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?";
|
||||
}
|
||||
@@ -172,6 +173,10 @@ pub mod db_drafts {
|
||||
pub const Q_012: &str = "SELECT id, draft_id, filename, content_type, size, file_path, checksum_sha256, created_at FROM draft_attachments WHERE draft_id=? ORDER BY created_at ASC FOR UPDATE";
|
||||
}
|
||||
|
||||
pub mod db_filter_match {
|
||||
pub const Q_001: &str = "SELECT uid, from_address, from_name, to_addresses, cc_addresses, subject, size, has_attachments, raw_headers FROM messages WHERE folder = ?";
|
||||
}
|
||||
|
||||
pub mod db_folder_preferences {
|
||||
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?";
|
||||
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
|
||||
|
||||
@@ -6,10 +6,11 @@ pub mod db_calendar {
|
||||
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
|
||||
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
|
||||
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
|
||||
created_at, updated_at";
|
||||
created_at, updated_at, reminder_extra_minutes";
|
||||
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
|
||||
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
|
||||
caldav_username, caldav_enabled, updated_at";
|
||||
caldav_username, caldav_enabled, updated_at, default_reminder_minutes, \
|
||||
default_extra_reminder_minutes";
|
||||
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP::text";
|
||||
pub const Q_002: &str = "INSERT INTO calendar_events
|
||||
(id, title, description, location, start_time, end_time, all_day,
|
||||
@@ -127,7 +128,7 @@ pub mod db_contacts {
|
||||
}
|
||||
|
||||
pub mod db_display_preferences {
|
||||
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, updated_at FROM display_preferences WHERE id = 1";
|
||||
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, theme_light_from, theme_dark_from, account_color, updated_at FROM display_preferences WHERE id = 1";
|
||||
pub const Q_002: &str =
|
||||
"INSERT INTO display_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING";
|
||||
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ${}";
|
||||
@@ -173,6 +174,10 @@ pub mod db_drafts {
|
||||
pub const Q_012: &str = "SELECT id, draft_id, filename, content_type, size, file_path, checksum_sha256, created_at FROM draft_attachments WHERE draft_id=$1 ORDER BY created_at ASC FOR UPDATE";
|
||||
}
|
||||
|
||||
pub mod db_filter_match {
|
||||
pub const Q_001: &str = "SELECT uid, from_address, from_name, to_addresses, cc_addresses, subject, size, has_attachments, raw_headers FROM messages WHERE folder = $1";
|
||||
}
|
||||
|
||||
pub mod db_folder_preferences {
|
||||
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = $1";
|
||||
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
|
||||
|
||||
@@ -6,10 +6,11 @@ pub mod db_calendar {
|
||||
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
|
||||
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
|
||||
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
|
||||
created_at, updated_at";
|
||||
created_at, updated_at, reminder_extra_minutes";
|
||||
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
|
||||
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
|
||||
caldav_username, caldav_enabled, updated_at";
|
||||
caldav_username, caldav_enabled, updated_at, default_reminder_minutes, \
|
||||
default_extra_reminder_minutes";
|
||||
pub const Q_001: &str = "SELECT datetime('now')";
|
||||
pub const Q_002: &str = "INSERT INTO calendar_events
|
||||
(id, title, description, location, start_time, end_time, all_day,
|
||||
@@ -125,7 +126,7 @@ pub mod db_contacts {
|
||||
}
|
||||
|
||||
pub mod db_display_preferences {
|
||||
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, updated_at FROM display_preferences WHERE id = 1";
|
||||
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, theme_light_from, theme_dark_from, account_color, updated_at FROM display_preferences WHERE id = 1";
|
||||
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
|
||||
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?{}";
|
||||
}
|
||||
@@ -170,6 +171,10 @@ pub mod db_drafts {
|
||||
pub const Q_012: &str = "SELECT id, draft_id, filename, content_type, size, file_path, checksum_sha256, created_at FROM draft_attachments WHERE draft_id=?1 ORDER BY created_at ASC";
|
||||
}
|
||||
|
||||
pub mod db_filter_match {
|
||||
pub const Q_001: &str = "SELECT uid, from_address, from_name, to_addresses, cc_addresses, subject, size, has_attachments, raw_headers FROM messages WHERE folder = ?1";
|
||||
}
|
||||
|
||||
pub mod db_folder_preferences {
|
||||
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1";
|
||||
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
|
||||
|
||||
@@ -142,7 +142,11 @@ fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) {
|
||||
event.status.trim().to_ascii_uppercase()
|
||||
));
|
||||
}
|
||||
if let Some(minutes) = event.reminder_minutes.filter(|value| *value >= 0) {
|
||||
for minutes in [event.reminder_minutes, event.reminder_extra_minutes]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|value| *value >= 0)
|
||||
{
|
||||
out.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\n");
|
||||
out.push_str(&format!("TRIGGER:-PT{minutes}M\r\n"));
|
||||
out.push_str("DESCRIPTION:Reminder\r\nEND:VALARM\r\n");
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
//! "Apply now" for mail filters: find the messages already in a folder that a
|
||||
//! visual filter rule matches, so the client can apply the rule's actions to
|
||||
//! existing mail (Sieve itself only ever runs on newly delivered messages).
|
||||
//!
|
||||
//! Matching runs on the IMAP server with `UID SEARCH`, so it covers the whole
|
||||
//! folder regardless of what RustBox has cached. IMAP SEARCH only does
|
||||
//! case-insensitive substring matching, though, while Sieve rules can also use
|
||||
//! `:is` / `:matches`, sizes, attachments and non-ASCII values. Every rule is
|
||||
//! therefore translated into two searches:
|
||||
//!
|
||||
//! * an *upper bound* — a superset of the real matches, and
|
||||
//! * a *lower bound* — a subset that is certainly correct.
|
||||
//!
|
||||
//! Exact leaves contribute the same criteria to both bounds; inexact leaves
|
||||
//! widen the upper bound to `ALL` (or a substring) and shrink the lower bound
|
||||
//! to `NOT ALL`; `NOT` swaps the bounds. Messages in the lower bound match.
|
||||
//! Messages only in the upper bound are decided exactly from the local message
|
||||
//! cache when it holds what the rule needs; anything still undecided is
|
||||
//! reported as `uncertain` and left alone.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::session::SessionState;
|
||||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::db::pool::{Connection, params};
|
||||
use crate::error::AppError;
|
||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
use crate::routes::messages::types::parse_address_list;
|
||||
|
||||
/// Upper limit on UIDs returned to the client in one preview.
|
||||
const MAX_RETURNED_UIDS: usize = 20_000;
|
||||
/// Rules deeper than this are rejected rather than evaluated.
|
||||
const MAX_RULE_DEPTH: usize = 12;
|
||||
const MAX_RULE_LEAVES: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MatchKind {
|
||||
Contains,
|
||||
Is,
|
||||
Matches,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RuleTest {
|
||||
pub kind: String,
|
||||
#[serde(rename = "match", default = "default_match")]
|
||||
pub match_kind: MatchKind,
|
||||
#[serde(default)]
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub header: String,
|
||||
}
|
||||
|
||||
fn default_match() -> MatchKind {
|
||||
MatchKind::Contains
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RuleExpr {
|
||||
Group { op: String, args: Vec<RuleExpr> },
|
||||
Not { not: Box<RuleExpr> },
|
||||
Test { test: RuleTest },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FilterMatchRequest {
|
||||
pub folder: String,
|
||||
pub rule: RuleExpr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FilterMatchResponse {
|
||||
pub folder: String,
|
||||
pub matched: usize,
|
||||
pub uids: Vec<u32>,
|
||||
pub truncated: bool,
|
||||
/// Candidates the server could not decide exactly (left untouched).
|
||||
pub uncertain: usize,
|
||||
}
|
||||
|
||||
/// One cached message row, as needed for exact evaluation.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CachedMessage {
|
||||
pub from_address: String,
|
||||
pub from_name: String,
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>,
|
||||
pub to_header: String,
|
||||
pub subject: String,
|
||||
pub size: i64,
|
||||
pub has_attachments: bool,
|
||||
pub raw_headers: Option<String>,
|
||||
}
|
||||
|
||||
/// IMAP search criteria for the upper and lower bound of a rule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bounds {
|
||||
pub upper: String,
|
||||
pub lower: String,
|
||||
}
|
||||
|
||||
const ALL: &str = "ALL";
|
||||
const NONE: &str = "NOT ALL";
|
||||
|
||||
fn validate_rule(expr: &RuleExpr, depth: usize, leaves: &mut usize) -> Result<(), String> {
|
||||
if depth > MAX_RULE_DEPTH {
|
||||
return Err("Filter rule is nested too deeply".to_string());
|
||||
}
|
||||
match expr {
|
||||
RuleExpr::Group { op, args } => {
|
||||
if op != "allof" && op != "anyof" {
|
||||
return Err(format!("Unknown rule operator: {op}"));
|
||||
}
|
||||
if args.is_empty() {
|
||||
return Err("Filter rule group is empty".to_string());
|
||||
}
|
||||
for arg in args {
|
||||
validate_rule(arg, depth + 1, leaves)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RuleExpr::Not { not } => validate_rule(not, depth + 1, leaves),
|
||||
RuleExpr::Test { test } => {
|
||||
*leaves += 1;
|
||||
if *leaves > MAX_RULE_LEAVES {
|
||||
return Err("Filter rule has too many conditions".to_string());
|
||||
}
|
||||
validate_test(test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_test(test: &RuleTest) -> Result<(), String> {
|
||||
match test.kind.as_str() {
|
||||
"from" | "from-domain" | "to" | "to-only" | "cc-only" | "header-from" | "header-to"
|
||||
| "subject" | "list-id" => {
|
||||
if test.value.trim().is_empty() {
|
||||
return Err(format!("Condition {} needs a value", test.kind));
|
||||
}
|
||||
}
|
||||
"header" => {
|
||||
if test.header.is_empty()
|
||||
|| !test
|
||||
.header
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err("Custom header name is invalid".to_string());
|
||||
}
|
||||
if test.value.trim().is_empty() {
|
||||
return Err("Custom header condition needs a value".to_string());
|
||||
}
|
||||
}
|
||||
"size-over" | "size-under" => {
|
||||
parse_size(&test.value).ok_or_else(|| format!("Invalid size: {}", test.value))?;
|
||||
}
|
||||
"has-attachment" | "priority-high" => {}
|
||||
other => return Err(format!("Unsupported condition: {other}")),
|
||||
}
|
||||
if test.value.contains(['\r', '\n']) {
|
||||
return Err("Condition values cannot contain line breaks".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `10`, `10K`, `2M`, `1G` (Sieve quantifiers) in bytes.
|
||||
pub fn parse_size(value: &str) -> Option<u64> {
|
||||
let value = value.trim();
|
||||
let (digits, unit) = match value.char_indices().last() {
|
||||
Some((index, ch)) if ch.is_ascii_alphabetic() => (&value[..index], Some(ch)),
|
||||
_ => (value, None),
|
||||
};
|
||||
let number: u64 = digits.trim().parse().ok()?;
|
||||
let factor = match unit.map(|ch| ch.to_ascii_uppercase()) {
|
||||
None => 1,
|
||||
Some('K') => 1024,
|
||||
Some('M') => 1024 * 1024,
|
||||
Some('G') => 1024 * 1024 * 1024,
|
||||
Some(_) => return None,
|
||||
};
|
||||
number.checked_mul(factor)
|
||||
}
|
||||
|
||||
fn quote(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// The literal text a value must contain, or `None` when IMAP cannot search
|
||||
/// for it (non-ASCII text needs `CHARSET` support we do not rely on).
|
||||
fn search_literal(test: &RuleTest) -> Option<String> {
|
||||
let value = test.value.trim();
|
||||
let literal = match test.match_kind {
|
||||
MatchKind::Matches => value
|
||||
.split(['*', '?'])
|
||||
.max_by_key(|part| part.len())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
if literal.is_empty() || !literal.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
Some(literal)
|
||||
}
|
||||
|
||||
fn bounds_for_test(test: &RuleTest) -> Bounds {
|
||||
let inexact = |upper: String| Bounds {
|
||||
upper,
|
||||
lower: NONE.to_string(),
|
||||
};
|
||||
let exact_for = |criteria: String, exact: bool| {
|
||||
if exact {
|
||||
Bounds {
|
||||
upper: criteria.clone(),
|
||||
lower: criteria,
|
||||
}
|
||||
} else {
|
||||
inexact(criteria)
|
||||
}
|
||||
};
|
||||
match test.kind.as_str() {
|
||||
"size-over" | "size-under" => {
|
||||
let bytes = parse_size(&test.value).unwrap_or(0);
|
||||
let keyword = if test.kind == "size-over" {
|
||||
"LARGER"
|
||||
} else {
|
||||
"SMALLER"
|
||||
};
|
||||
exact_for(format!("{keyword} {bytes}"), true)
|
||||
}
|
||||
"has-attachment" => inexact(ALL.to_string()),
|
||||
"priority-high" => exact_for(
|
||||
"OR HEADER X-Priority \"1\" OR HEADER X-Priority \"2\" HEADER Importance \"high\""
|
||||
.to_string(),
|
||||
false,
|
||||
),
|
||||
// `:matches "*"` on a header is "the header exists"; IMAP's
|
||||
// `HEADER name ""` is exactly that.
|
||||
"list-id" | "header"
|
||||
if matches!(test.match_kind, MatchKind::Matches)
|
||||
&& !test.value.trim().is_empty()
|
||||
&& test.value.trim().chars().all(|c| c == '*') =>
|
||||
{
|
||||
let name = if test.kind == "list-id" {
|
||||
"List-Id"
|
||||
} else {
|
||||
test.header.as_str()
|
||||
};
|
||||
exact_for(format!("HEADER {name} \"\""), true)
|
||||
}
|
||||
_ => {
|
||||
let Some(literal) = search_literal(test) else {
|
||||
return inexact(ALL.to_string());
|
||||
};
|
||||
// IMAP SEARCH does case-insensitive substring matching on the whole
|
||||
// header text. That is exact for Sieve `header :contains` tests;
|
||||
// `:is` / `:matches` and Sieve `address` tests (which ignore the
|
||||
// display name) are only a superset and are narrowed afterwards
|
||||
// from the cache.
|
||||
let substring = matches!(test.match_kind, MatchKind::Contains);
|
||||
let q = quote(&literal);
|
||||
match test.kind.as_str() {
|
||||
"header-from" => exact_for(format!("FROM {q}"), substring),
|
||||
"header-to" => exact_for(format!("TO {q}"), substring),
|
||||
"subject" => exact_for(format!("SUBJECT {q}"), substring),
|
||||
"list-id" => exact_for(format!("HEADER List-Id {q}"), substring),
|
||||
"header" => exact_for(format!("HEADER {} {q}", test.header), substring),
|
||||
"from" => inexact(format!("FROM {q}")),
|
||||
"from-domain" if matches!(test.match_kind, MatchKind::Is) => {
|
||||
inexact(format!("FROM {}", quote(&format!("@{literal}"))))
|
||||
}
|
||||
"from-domain" => inexact(format!("FROM {q}")),
|
||||
"to" => inexact(format!("OR TO {q} CC {q}")),
|
||||
"to-only" => inexact(format!("TO {q}")),
|
||||
"cc-only" => inexact(format!("CC {q}")),
|
||||
_ => inexact(ALL.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(op: &str, parts: Vec<String>) -> String {
|
||||
if parts.len() == 1 {
|
||||
return parts.into_iter().next().unwrap_or_else(|| ALL.to_string());
|
||||
}
|
||||
if op == "allof" {
|
||||
parts
|
||||
.into_iter()
|
||||
.map(|part| format!("({part})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
// IMAP OR is binary: OR a OR b c
|
||||
let mut iter = parts.into_iter().rev();
|
||||
let mut acc = iter.next().unwrap_or_else(|| NONE.to_string());
|
||||
for part in iter {
|
||||
acc = format!("OR ({part}) ({acc})");
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bounds(expr: &RuleExpr) -> Bounds {
|
||||
match expr {
|
||||
RuleExpr::Test { test } => bounds_for_test(test),
|
||||
RuleExpr::Not { not } => {
|
||||
let inner = bounds(not);
|
||||
Bounds {
|
||||
upper: format!("NOT ({})", inner.lower),
|
||||
lower: format!("NOT ({})", inner.upper),
|
||||
}
|
||||
}
|
||||
RuleExpr::Group { op, args } => {
|
||||
let parts: Vec<Bounds> = args.iter().map(bounds).collect();
|
||||
Bounds {
|
||||
upper: combine(op, parts.iter().map(|b| b.upper.clone()).collect()),
|
||||
lower: combine(op, parts.iter().map(|b| b.lower.clone()).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||
let pattern: Vec<char> = pattern.chars().collect();
|
||||
let text: Vec<char> = text.chars().collect();
|
||||
let (mut p, mut t) = (0usize, 0usize);
|
||||
let (mut star, mut mark) = (None::<usize>, 0usize);
|
||||
while t < text.len() {
|
||||
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
|
||||
p += 1;
|
||||
t += 1;
|
||||
} else if p < pattern.len() && pattern[p] == '*' {
|
||||
star = Some(p);
|
||||
mark = t;
|
||||
p += 1;
|
||||
} else if let Some(star_index) = star {
|
||||
p = star_index + 1;
|
||||
mark += 1;
|
||||
t = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while p < pattern.len() && pattern[p] == '*' {
|
||||
p += 1;
|
||||
}
|
||||
p == pattern.len()
|
||||
}
|
||||
|
||||
fn compare(match_kind: &MatchKind, value: &str, candidate: &str) -> bool {
|
||||
let value = value.trim().to_lowercase();
|
||||
let candidate = candidate.trim().to_lowercase();
|
||||
match match_kind {
|
||||
MatchKind::Contains => candidate.contains(&value),
|
||||
MatchKind::Is => candidate == value,
|
||||
MatchKind::Matches => wildcard_match(&value, &candidate),
|
||||
}
|
||||
}
|
||||
|
||||
fn any(match_kind: &MatchKind, value: &str, candidates: &[String]) -> bool {
|
||||
candidates
|
||||
.iter()
|
||||
.any(|candidate| compare(match_kind, value, candidate))
|
||||
}
|
||||
|
||||
/// Unfolded values of `name` in a raw RFC 5322 header block.
|
||||
fn header_values(raw: &str, name: &str) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
let mut current: Option<String> = None;
|
||||
for line in raw.lines() {
|
||||
if line.starts_with([' ', '\t']) {
|
||||
if let Some(value) = current.as_mut() {
|
||||
value.push(' ');
|
||||
value.push_str(line.trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = current.take() {
|
||||
values.push(value);
|
||||
}
|
||||
if let Some((field, value)) = line.split_once(':')
|
||||
&& field.trim().eq_ignore_ascii_case(name)
|
||||
{
|
||||
current = Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = current {
|
||||
values.push(value);
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
/// Exact evaluation of one test against the cache; `None` when the cache does
|
||||
/// not hold what the test needs.
|
||||
fn eval_test(test: &RuleTest, message: &CachedMessage) -> Option<bool> {
|
||||
let value = test.value.as_str();
|
||||
let m = &test.match_kind;
|
||||
let raw_header = |name: &str| -> Option<Vec<String>> {
|
||||
message
|
||||
.raw_headers
|
||||
.as_deref()
|
||||
.map(|raw| header_values(raw, name))
|
||||
};
|
||||
Some(match test.kind.as_str() {
|
||||
"from" => compare(m, value, &message.from_address),
|
||||
"from-domain" => {
|
||||
let domain = message
|
||||
.from_address
|
||||
.rsplit_once('@')
|
||||
.map(|(_, domain)| domain)
|
||||
.unwrap_or("");
|
||||
compare(m, value, domain)
|
||||
}
|
||||
"to" => {
|
||||
let all: Vec<String> = message
|
||||
.to
|
||||
.iter()
|
||||
.chain(message.cc.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
any(m, value, &all)
|
||||
}
|
||||
"to-only" => any(m, value, &message.to),
|
||||
"cc-only" => any(m, value, &message.cc),
|
||||
"header-from" => {
|
||||
let display = if message.from_name.is_empty() {
|
||||
message.from_address.clone()
|
||||
} else {
|
||||
format!("{} <{}>", message.from_name, message.from_address)
|
||||
};
|
||||
compare(m, value, &display)
|
||||
}
|
||||
"header-to" => compare(m, value, &message.to_header),
|
||||
"subject" => compare(m, value, &message.subject),
|
||||
"size-over" => message.size > parse_size(value)? as i64,
|
||||
"size-under" => message.size < parse_size(value)? as i64,
|
||||
"has-attachment" => message.has_attachments,
|
||||
"list-id" => any(m, value, &raw_header("List-Id")?),
|
||||
"header" => any(m, value, &raw_header(&test.header)?),
|
||||
"priority-high" => {
|
||||
let priority = raw_header("X-Priority")?;
|
||||
let importance = raw_header("Importance")?;
|
||||
priority
|
||||
.iter()
|
||||
.any(|p| p.trim_start().starts_with('1') || p.trim_start().starts_with('2'))
|
||||
|| importance
|
||||
.iter()
|
||||
.any(|i| i.trim().eq_ignore_ascii_case("high"))
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Three-valued evaluation: `None` means "cannot decide from the cache".
|
||||
pub fn eval(expr: &RuleExpr, message: &CachedMessage) -> Option<bool> {
|
||||
match expr {
|
||||
RuleExpr::Test { test } => eval_test(test, message),
|
||||
RuleExpr::Not { not } => eval(not, message).map(|value| !value),
|
||||
RuleExpr::Group { op, args } => {
|
||||
let mut unknown = false;
|
||||
for arg in args {
|
||||
match (op.as_str(), eval(arg, message)) {
|
||||
("allof", Some(false)) => return Some(false),
|
||||
("anyof", Some(true)) => return Some(true),
|
||||
(_, None) => unknown = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if unknown { None } else { Some(op == "allof") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_cache(conn: &Connection, folder: &str) -> Result<HashMap<u32, CachedMessage>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(crate::dbq!(db_filter_match::Q_001))
|
||||
.map_err(|e| format!("Failed to prepare filter cache query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![folder], |row| {
|
||||
let uid: i64 = row.get(0)?;
|
||||
let to_raw: String = row.get(3)?;
|
||||
let cc_raw: String = row.get(4)?;
|
||||
let addresses = |raw: &str| -> Vec<String> {
|
||||
parse_address_list(raw)
|
||||
.into_iter()
|
||||
.map(|entry| entry.address)
|
||||
.collect()
|
||||
};
|
||||
let to_header = parse_address_list(&to_raw)
|
||||
.into_iter()
|
||||
.map(|entry| match entry.name {
|
||||
Some(name) if !name.is_empty() => format!("{name} <{}>", entry.address),
|
||||
_ => entry.address,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Ok((
|
||||
uid.max(0) as u32,
|
||||
CachedMessage {
|
||||
from_address: row.get(1)?,
|
||||
from_name: row.get(2)?,
|
||||
to: addresses(&to_raw),
|
||||
cc: addresses(&cc_raw),
|
||||
to_header,
|
||||
subject: row.get(5)?,
|
||||
size: row.get(6)?,
|
||||
has_attachments: row.get::<_, i64>(7)? != 0,
|
||||
raw_headers: row.get(8)?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| format!("Failed to read filter cache: {e}"))?;
|
||||
let mut cache = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, message) = row.map_err(|e| format!("Failed to read cached message: {e}"))?;
|
||||
cache.insert(uid, message);
|
||||
}
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
/// Combines the two IMAP result sets with exact cache evaluation.
|
||||
pub fn resolve_matches(
|
||||
rule: &RuleExpr,
|
||||
upper: &[u32],
|
||||
lower: &[u32],
|
||||
cache: &HashMap<u32, CachedMessage>,
|
||||
) -> (Vec<u32>, usize) {
|
||||
let lower_set: HashSet<u32> = lower.iter().copied().collect();
|
||||
let candidates: std::collections::BTreeSet<u32> =
|
||||
upper.iter().chain(lower.iter()).copied().collect();
|
||||
let mut matched: Vec<u32> = Vec::new();
|
||||
let mut uncertain = 0usize;
|
||||
for uid in candidates {
|
||||
let decided = if lower_set.contains(&uid) {
|
||||
Some(true)
|
||||
} else {
|
||||
cache.get(&uid).and_then(|message| eval(rule, message))
|
||||
};
|
||||
match decided {
|
||||
Some(true) => matched.push(uid),
|
||||
Some(false) => {}
|
||||
None => uncertain += 1,
|
||||
}
|
||||
}
|
||||
(matched, uncertain)
|
||||
}
|
||||
|
||||
fn build_creds(session: &SessionState) -> ImapCredentials {
|
||||
ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/filters/match` — which messages already in `folder` the rule matches.
|
||||
pub async fn match_filter_handler(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Json(body): Json<FilterMatchRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
if body.folder.trim().is_empty() || body.folder.contains(['\r', '\n']) {
|
||||
return Err(AppError::BadRequest("A folder is required".to_string()));
|
||||
}
|
||||
let mut leaves = 0usize;
|
||||
validate_rule(&body.rule, 0, &mut leaves).map_err(AppError::BadRequest)?;
|
||||
let bounds = bounds(&body.rule);
|
||||
let creds = build_creds(&session);
|
||||
let upper = imap_client
|
||||
.search_uids(&creds, &body.folder, &bounds.upper)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP search failed: {e}")))?;
|
||||
let lower = if bounds.lower == bounds.upper {
|
||||
upper.clone()
|
||||
} else {
|
||||
imap_client
|
||||
.search_uids(&creds, &body.folder, &bounds.lower)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP search failed: {e}")))?
|
||||
};
|
||||
let needs_cache = upper.len() > lower.len();
|
||||
let cache = if needs_cache {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
||||
load_cache(&conn, &body.folder).map_err(AppError::InternalError)?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let (mut uids, uncertain) = resolve_matches(&body.rule, &upper, &lower, &cache);
|
||||
let matched = uids.len();
|
||||
let truncated = uids.len() > MAX_RETURNED_UIDS;
|
||||
uids.truncate(MAX_RETURNED_UIDS);
|
||||
Ok(Json(FilterMatchResponse {
|
||||
folder: body.folder,
|
||||
matched,
|
||||
uids,
|
||||
truncated,
|
||||
uncertain,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test(kind: &str, match_kind: &str, value: &str) -> RuleExpr {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"test": {"kind": kind, "match": match_kind, "value": value}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn message(from: &str, subject: &str) -> CachedMessage {
|
||||
CachedMessage {
|
||||
from_address: from.to_string(),
|
||||
subject: subject.to_string(),
|
||||
..CachedMessage::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substring_tests_are_exact_in_both_bounds() {
|
||||
let b = bounds(&test("header-from", "contains", "alice@"));
|
||||
assert_eq!(b.upper, "FROM \"alice@\"");
|
||||
assert_eq!(b.lower, b.upper);
|
||||
// Sieve `address` tests ignore display names, IMAP FROM does not.
|
||||
let address = bounds(&test("from", "contains", "alice@"));
|
||||
assert_eq!(address.upper, "FROM \"alice@\"");
|
||||
assert_eq!(address.lower, "NOT ALL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_and_wildcard_tests_only_widen_the_upper_bound() {
|
||||
let b = bounds(&test("subject", "is", "Invoice"));
|
||||
assert_eq!(b.upper, "SUBJECT \"Invoice\"");
|
||||
assert_eq!(b.lower, "NOT ALL");
|
||||
let w = bounds(&test("subject", "matches", "Invoice *2026*"));
|
||||
assert_eq!(w.upper, "SUBJECT \"Invoice \"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negation_swaps_bounds_and_non_ascii_falls_back_to_the_cache() {
|
||||
let rule: RuleExpr = serde_json::from_value(serde_json::json!({
|
||||
"not": {"test": {"kind": "subject", "match": "contains", "value": "faktura"}}
|
||||
}))
|
||||
.unwrap();
|
||||
let b = bounds(&rule);
|
||||
assert_eq!(b.upper, "NOT (SUBJECT \"faktura\")");
|
||||
assert_eq!(b.lower, b.upper);
|
||||
let polish = bounds(&test("subject", "contains", "zażółć"));
|
||||
assert_eq!(polish.upper, "ALL");
|
||||
assert_eq!(polish.lower, "NOT ALL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_use_binary_imap_or_and_parenthesised_and() {
|
||||
let rule: RuleExpr = serde_json::from_value(serde_json::json!({
|
||||
"op": "anyof",
|
||||
"args": [
|
||||
{"test": {"kind": "subject", "value": "a"}},
|
||||
{"test": {"kind": "subject", "value": "b"}},
|
||||
{"test": {"kind": "subject", "value": "c"}}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bounds(&rule).upper,
|
||||
"OR (SUBJECT \"a\") (OR (SUBJECT \"b\") (SUBJECT \"c\"))"
|
||||
);
|
||||
let all: RuleExpr = serde_json::from_value(serde_json::json!({
|
||||
"op": "allof",
|
||||
"args": [{"test": {"kind": "size-over", "value": "2M"}}, {"test": {"kind": "subject", "value": "x"}}]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(bounds(&all).upper, "(LARGER 2097152) (SUBJECT \"x\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_in_values_are_escaped() {
|
||||
assert_eq!(
|
||||
bounds(&test("subject", "contains", "say \"hi\"\\")).upper,
|
||||
"SUBJECT \"say \\\"hi\\\"\\\\\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_evaluation_decides_exact_and_wildcard_matches() {
|
||||
let rule = test("subject", "is", "Invoice");
|
||||
assert_eq!(eval(&rule, &message("a@x", "invoice")), Some(true));
|
||||
assert_eq!(eval(&rule, &message("a@x", "Invoice 12")), Some(false));
|
||||
let pattern = test("from-domain", "matches", "*.example.com");
|
||||
assert_eq!(
|
||||
eval(&pattern, &message("a@mail.example.com", "")),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(eval(&pattern, &message("a@example.org", "")), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_tests_without_cached_headers_are_undecided() {
|
||||
let rule = test("list-id", "is", "news.example.com");
|
||||
assert_eq!(eval(&rule, &message("a@x", "")), None);
|
||||
let mut cached = message("a@x", "");
|
||||
cached.raw_headers =
|
||||
Some("List-Id: Weekly\r\n <news.example.com>\r\nSubject: x".to_string());
|
||||
let contains = test("list-id", "contains", "news.example.com");
|
||||
assert_eq!(eval(&contains, &cached), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_combines_imap_bounds_with_the_cache() {
|
||||
let rule = test("subject", "is", "Invoice");
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert(1, message("a@x", "Invoice"));
|
||||
cache.insert(2, message("a@x", "Invoice reminder"));
|
||||
let (matched, uncertain) = resolve_matches(&rule, &[1, 2, 3], &[], &cache);
|
||||
assert_eq!(matched, vec![1]);
|
||||
assert_eq!(uncertain, 1);
|
||||
let (matched, uncertain) = resolve_matches(&rule, &[4, 5], &[4, 5], &HashMap::new());
|
||||
assert_eq!(matched, vec![4, 5]);
|
||||
assert_eq!(uncertain, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_are_validated() {
|
||||
let mut leaves = 0;
|
||||
assert!(validate_rule(&test("header", "contains", "x"), 0, &mut leaves).is_err());
|
||||
let mut leaves = 0;
|
||||
assert!(validate_rule(&test("size-over", "contains", "lots"), 0, &mut leaves).is_err());
|
||||
let mut leaves = 0;
|
||||
assert!(validate_rule(&test("subject", "contains", "a\r\nb"), 0, &mut leaves).is_err());
|
||||
let mut leaves = 0;
|
||||
assert!(validate_rule(&test("has-attachment", "contains", ""), 0, &mut leaves).is_ok());
|
||||
assert_eq!(parse_size("10K"), Some(10 * 1024));
|
||||
assert_eq!(parse_size("3"), Some(3));
|
||||
assert_eq!(parse_size("1X"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcards_follow_sieve_semantics() {
|
||||
assert!(wildcard_match("a*c", "abbbc"));
|
||||
assert!(wildcard_match("a?c", "abc"));
|
||||
assert!(!wildcard_match("a?c", "abbc"));
|
||||
assert!(wildcard_match("*", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_only_header_means_header_exists() {
|
||||
let rule = RuleExpr::Test {
|
||||
test: RuleTest {
|
||||
kind: "list-id".to_string(),
|
||||
match_kind: MatchKind::Matches,
|
||||
value: "*".to_string(),
|
||||
header: String::new(),
|
||||
},
|
||||
};
|
||||
let b = bounds(&rule);
|
||||
assert_eq!(b.upper, "HEADER List-Id \"\"");
|
||||
assert_eq!(b.lower, b.upper);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod contacts;
|
||||
pub mod diagnostics;
|
||||
pub mod display_preferences;
|
||||
pub mod drafts;
|
||||
pub mod filters;
|
||||
pub mod folder_counts;
|
||||
pub mod folder_mgmt;
|
||||
pub mod folder_preferences;
|
||||
@@ -425,6 +426,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
post(tags::tag_message_handler).get(tags::list_tag_messages_handler),
|
||||
)
|
||||
.route("/tags/{id}/messages/bulk", post(tags::bulk_tag_handler))
|
||||
.route("/filters/match", post(filters::match_filter_handler))
|
||||
.route(
|
||||
"/tags/{id}/messages/{folder}/{uid}",
|
||||
delete(tags::untag_message_handler),
|
||||
|
||||
@@ -2977,3 +2977,99 @@ async fn sends_with_attachments_release_their_build_slot() {
|
||||
}
|
||||
assert_eq!(mock_imap.appended.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filter_match_narrows_imap_candidates_from_the_cache() {
|
||||
let static_dir = setup_static_dir();
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let config = test_config_with_imap(
|
||||
static_dir.path().to_str().unwrap(),
|
||||
data_dir.path().to_str().unwrap(),
|
||||
);
|
||||
let store = test_store();
|
||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||
|
||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||
let conn =
|
||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||
for (uid, from) in [(1u32, "news@shop.example"), (2u32, "friend@example.com")] {
|
||||
crate::db::messages::upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
uid,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"Hello",
|
||||
from,
|
||||
"",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
|
||||
let header = |uid: u32, address: &str| ImapMessageHeader {
|
||||
uid,
|
||||
subject: Some("Hello".to_string()),
|
||||
from: vec![EmailAddress {
|
||||
name: None,
|
||||
address: address.to_string(),
|
||||
}],
|
||||
to: vec![],
|
||||
date: None,
|
||||
flags: vec![],
|
||||
has_attachments: false,
|
||||
size: 100,
|
||||
message_id: None,
|
||||
in_reply_to: None,
|
||||
references: None,
|
||||
cc: vec![],
|
||||
reaction: None,
|
||||
};
|
||||
// The mock answers every search with all UIDs, i.e. a pure superset, so
|
||||
// the exact answer has to come from the cache.
|
||||
let mock = MockImapClient::new().with_headers(vec![
|
||||
header(1, "news@shop.example"),
|
||||
header(2, "friend@example.com"),
|
||||
]);
|
||||
let imap_client: Arc<dyn ImapClient> = Arc::new(mock);
|
||||
let app = create_router(
|
||||
config,
|
||||
store,
|
||||
imap_client,
|
||||
test_smtp_client(),
|
||||
test_search_engine(data_dir.path().to_str().unwrap()),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/filters/match")
|
||||
.header("x-requested-with", "XMLHttpRequest")
|
||||
.header("content-type", "application/json");
|
||||
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
let body = r#"{"folder":"INBOX","rule":{"test":{"kind":"from-domain","match":"is","value":"shop.example"}}}"#;
|
||||
let response = app
|
||||
.oneshot(req.body(Body::from(body)).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(json["uids"], serde_json::json!([1]));
|
||||
assert_eq!(json["matched"], 1);
|
||||
assert_eq!(json["uncertain"], 0);
|
||||
}
|
||||
+20
-1
@@ -222,10 +222,19 @@
|
||||
}
|
||||
|
||||
.message-subject {
|
||||
flex: 0 1 auto;
|
||||
min-width: min(100%, 14ch);
|
||||
max-width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-subject-line .message-tags {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-snippet {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -1067,7 +1076,7 @@ html[data-reduce-motion="true"] #thread-toggle svg {
|
||||
|
||||
|
||||
.message-row.flagged {
|
||||
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent);
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--danger) 72%, transparent);
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
@@ -1157,6 +1166,16 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
|
||||
|
||||
|
||||
|
||||
@container reader-pane (max-width: 820px) {
|
||||
.reader-toolbar .button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reader-toolbar .button {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@container reader-pane (max-width: 640px) {
|
||||
.reader-toolbar {
|
||||
gap: 1px;
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.app-prompt-control {
|
||||
@@ -444,6 +445,24 @@
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-2);
|
||||
box-shadow: 0 12px 28px var(--shadow);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
z-index: 1001;
|
||||
max-width: min(380px, calc(100vw - 12px));
|
||||
}
|
||||
|
||||
.context-menu button.has-submenu[aria-expanded="true"] {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.context-menu-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
@@ -1123,6 +1142,58 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.sieve-exception-row {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.sieve-templates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sieve-template-chip {
|
||||
min-height: 26px;
|
||||
padding: 3px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--field);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sieve-template-chip:hover,
|
||||
.sieve-template-chip:focus-visible {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sieve-apply-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sieve-apply-row label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 200px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.sieve-apply-result {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sieve-apply-result:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sieve-rule-preview {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
@@ -2562,3 +2633,163 @@ kbd {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* 0.2.24: theme schedule, account colour, contrast hint, list skeleton and
|
||||
a short cross-fade when the theme or interface style changes. */
|
||||
.theme-schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-schedule-row.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-color-row {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.account-color-row > span {
|
||||
margin-bottom: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.account-color-row small {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.account-color-row .color-input {
|
||||
grid-template-columns: 16px 44px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.account-color-row .color-input input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-color-row .color-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contrast-warning {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
/* Fixed colours on purpose: the warning must stay readable precisely when
|
||||
the user's own text/panel colours are not. */
|
||||
border: 1px solid #d9a93a;
|
||||
border-radius: var(--radius-control);
|
||||
background: #fff4d6;
|
||||
color: #3d2e00;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.contrast-warning.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-account-color="true"] .appbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html[data-account-color="true"] .appbar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0 0 auto;
|
||||
height: 3px;
|
||||
background: var(--account-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html[data-account-color="true"] #account-email::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--account-color);
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.message-skeleton-row {
|
||||
display: grid;
|
||||
grid-template-columns: 19px minmax(0, 1fr) 46px;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
gap: 7px 8px;
|
||||
min-height: var(--row-height);
|
||||
padding: 9px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.skeleton-dot,
|
||||
.skeleton-line {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text) 9%, transparent);
|
||||
animation: rustbox-skeleton-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-dot {
|
||||
grid-row: 1 / 3;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
width: var(--w, 60%);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.skeleton-date {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-wide {
|
||||
grid-column: 2 / 4;
|
||||
height: 7px;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.message-skeleton-row:nth-child(odd) .skeleton-line {
|
||||
animation-delay: .15s;
|
||||
}
|
||||
|
||||
@keyframes rustbox-skeleton-pulse {
|
||||
50% {
|
||||
opacity: .45;
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-transition,
|
||||
html.theme-transition *,
|
||||
html.theme-transition *::before,
|
||||
html.theme-transition *::after {
|
||||
transition: background-color .3s ease, color .3s ease, border-color .3s ease, fill .3s ease !important;
|
||||
}
|
||||
|
||||
html[data-reduce-motion="true"] :is(.skeleton-dot, .skeleton-line) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:is(.skeleton-dot, .skeleton-line) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html.theme-transition,
|
||||
html.theme-transition * {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1853,3 +1853,330 @@ html[data-interface-skin="retro"][data-theme="dark"] {
|
||||
--reader-bg: #232327;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 0.2.24 predefined styles: light and dark palettes. */
|
||||
html[data-interface-skin="winxp"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #ece9d8;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f6f4ec;
|
||||
--panel-3: #e3dfcc;
|
||||
--sidebar: #d6dff7;
|
||||
--line: #d0ccb8;
|
||||
--line-strong: #7f9db9;
|
||||
--text: #000000;
|
||||
--muted: #3b3b3b;
|
||||
--subtle: #5f5f5f;
|
||||
--accent: #245edb;
|
||||
--accent-strong: #1941a5;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c0281e;
|
||||
--warning: #8a6300;
|
||||
--success: #2e7d1f;
|
||||
--field: #ffffff;
|
||||
--selected: #c1d2ee;
|
||||
--hover: #e8eef9;
|
||||
--shadow: rgba(0, 0, 0, .25);
|
||||
--folder-bg: #d6dff7;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="winxp"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1b1d24;
|
||||
--panel: #23262f;
|
||||
--panel-2: #2a2e39;
|
||||
--panel-3: #343947;
|
||||
--sidebar: #1d2433;
|
||||
--line: #353b4a;
|
||||
--line-strong: #4d5a78;
|
||||
--text: #f0f2f7;
|
||||
--muted: #b6bdcc;
|
||||
--subtle: #8d95a8;
|
||||
--accent: #4c8bf5;
|
||||
--accent-strong: #75a6ff;
|
||||
--accent-ink: #071226;
|
||||
--danger: #ff6b5e;
|
||||
--warning: #e8c15a;
|
||||
--success: #5cc74a;
|
||||
--field: #1b1d24;
|
||||
--selected: #2c4370;
|
||||
--hover: #2b3040;
|
||||
--shadow: rgba(0, 0, 0, .5);
|
||||
--folder-bg: #1d2433;
|
||||
--message-bg: #23262f;
|
||||
--reader-bg: #23262f;
|
||||
}
|
||||
|
||||
html[data-interface-skin="osx"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #ececec;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f5f5f5;
|
||||
--panel-3: #e3e3e3;
|
||||
--sidebar: #d6dde5;
|
||||
--line: #c8c8c8;
|
||||
--line-strong: #8e8e8e;
|
||||
--text: #000000;
|
||||
--muted: #4a4a4a;
|
||||
--subtle: #6d6d6d;
|
||||
--accent: #3875d7;
|
||||
--accent-strong: #1f5bbf;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c4302b;
|
||||
--warning: #8c6a00;
|
||||
--success: #2d8a2d;
|
||||
--field: #ffffff;
|
||||
--selected: #c5d8f4;
|
||||
--hover: #edf3fb;
|
||||
--shadow: rgba(0, 0, 0, .3);
|
||||
--folder-bg: #d6dde5;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="osx"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1e1f22;
|
||||
--panel: #26272b;
|
||||
--panel-2: #2d2e33;
|
||||
--panel-3: #36373d;
|
||||
--sidebar: #2a2c31;
|
||||
--line: #3a3b41;
|
||||
--line-strong: #5c5e66;
|
||||
--text: #ececee;
|
||||
--muted: #b4b5bb;
|
||||
--subtle: #8b8c93;
|
||||
--accent: #5a9cf8;
|
||||
--accent-strong: #82b4ff;
|
||||
--accent-ink: #06152b;
|
||||
--danger: #ff6961;
|
||||
--warning: #e6c35a;
|
||||
--success: #5ccf5c;
|
||||
--field: #1e1f22;
|
||||
--selected: #2f4b73;
|
||||
--hover: #303137;
|
||||
--shadow: rgba(0, 0, 0, .5);
|
||||
--folder-bg: #2a2c31;
|
||||
--message-bg: #26272b;
|
||||
--reader-bg: #26272b;
|
||||
}
|
||||
|
||||
html[data-interface-skin="ios6"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #c5ccd4;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f7f7f7;
|
||||
--panel-3: #e8ebef;
|
||||
--sidebar: #e3e7ec;
|
||||
--line: #cbd2d9;
|
||||
--line-strong: #9aa5b1;
|
||||
--text: #000000;
|
||||
--muted: #4c566a;
|
||||
--subtle: #6a7583;
|
||||
--accent: #3d6aa6;
|
||||
--accent-strong: #2c5285;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c8262b;
|
||||
--warning: #8a6a00;
|
||||
--success: #3a8b2b;
|
||||
--field: #ffffff;
|
||||
--selected: #cfe0f7;
|
||||
--hover: #eef2f6;
|
||||
--shadow: rgba(0, 0, 0, .35);
|
||||
--folder-bg: #e3e7ec;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="ios6"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #15181d;
|
||||
--panel: #1f2329;
|
||||
--panel-2: #262b32;
|
||||
--panel-3: #2f353e;
|
||||
--sidebar: #1a1e24;
|
||||
--line: #343b45;
|
||||
--line-strong: #56606d;
|
||||
--text: #f2f4f7;
|
||||
--muted: #b8c0cb;
|
||||
--subtle: #8a94a1;
|
||||
--accent: #6a9ae0;
|
||||
--accent-strong: #8fb3ea;
|
||||
--accent-ink: #08142a;
|
||||
--danger: #ff6b6b;
|
||||
--warning: #e9c25a;
|
||||
--success: #6fcf5c;
|
||||
--field: #15181d;
|
||||
--selected: #2c4366;
|
||||
--hover: #29303a;
|
||||
--shadow: rgba(0, 0, 0, .55);
|
||||
--folder-bg: #1a1e24;
|
||||
--message-bg: #1f2329;
|
||||
--reader-bg: #1f2329;
|
||||
}
|
||||
|
||||
html[data-interface-skin="win7"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #dbe6f4;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f5f9fd;
|
||||
--panel-3: #e3ecf7;
|
||||
--sidebar: #eef4fb;
|
||||
--line: #d5dfea;
|
||||
--line-strong: #8ea3bd;
|
||||
--text: #1e1e1e;
|
||||
--muted: #3f4c5c;
|
||||
--subtle: #5f7086;
|
||||
--accent: #1f6fc4;
|
||||
--accent-strong: #175aa0;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c42b1c;
|
||||
--warning: #8a6200;
|
||||
--success: #2e7d32;
|
||||
--field: #ffffff;
|
||||
--selected: #cce8ff;
|
||||
--hover: #e5f3ff;
|
||||
--shadow: rgba(0, 0, 0, .28);
|
||||
--folder-bg: #eef4fb;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="win7"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1724;
|
||||
--panel: #18212f;
|
||||
--panel-2: #1e2939;
|
||||
--panel-3: #263347;
|
||||
--sidebar: #142033;
|
||||
--line: #2a384d;
|
||||
--line-strong: #456184;
|
||||
--text: #eef3fa;
|
||||
--muted: #b2c0d3;
|
||||
--subtle: #8395ad;
|
||||
--accent: #5ba7ff;
|
||||
--accent-strong: #86beff;
|
||||
--accent-ink: #04111f;
|
||||
--danger: #ff6b5e;
|
||||
--warning: #ffd166;
|
||||
--success: #6ad36a;
|
||||
--field: #0f1724;
|
||||
--selected: #24466e;
|
||||
--hover: #213046;
|
||||
--shadow: rgba(0, 0, 0, .5);
|
||||
--folder-bg: #142033;
|
||||
--message-bg: #18212f;
|
||||
--reader-bg: #18212f;
|
||||
}
|
||||
|
||||
html[data-interface-skin="frutiger-aero"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #e6f6ff;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f3fbff;
|
||||
--panel-3: #dff2fb;
|
||||
--sidebar: #eefaf3;
|
||||
--line: #cde7f2;
|
||||
--line-strong: #8fc3d9;
|
||||
--text: #0b2a3a;
|
||||
--muted: #2f5668;
|
||||
--subtle: #557a8a;
|
||||
--accent: #0b7fc0;
|
||||
--accent-strong: #08679c;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #d23a3a;
|
||||
--warning: #8a6800;
|
||||
--success: #2f8a2f;
|
||||
--field: #ffffff;
|
||||
--selected: #c9ecff;
|
||||
--hover: #ebf8ff;
|
||||
--shadow: rgba(0, 70, 110, .18);
|
||||
--folder-bg: #eefaf3;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="frutiger-aero"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #04131f;
|
||||
--panel: #0a2030;
|
||||
--panel-2: #0e293b;
|
||||
--panel-3: #133449;
|
||||
--sidebar: #062433;
|
||||
--line: #16405a;
|
||||
--line-strong: #2d6987;
|
||||
--text: #e8f7ff;
|
||||
--muted: #a9d3e6;
|
||||
--subtle: #7aa9bf;
|
||||
--accent: #39c0ff;
|
||||
--accent-strong: #74d4ff;
|
||||
--accent-ink: #02121d;
|
||||
--danger: #ff7a7a;
|
||||
--warning: #ffd166;
|
||||
--success: #6ee36e;
|
||||
--field: #04131f;
|
||||
--selected: #164c6e;
|
||||
--hover: #0f3046;
|
||||
--shadow: rgba(0, 0, 0, .5);
|
||||
--folder-bg: #062433;
|
||||
--message-bg: #0a2030;
|
||||
--reader-bg: #0a2030;
|
||||
}
|
||||
|
||||
html[data-interface-skin="beos"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #d8d8d8;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f0f0f0;
|
||||
--panel-3: #dedede;
|
||||
--sidebar: #e6e6e6;
|
||||
--line: #b8b8b8;
|
||||
--line-strong: #7a7a7a;
|
||||
--text: #000000;
|
||||
--muted: #3a3a3a;
|
||||
--subtle: #5e5e5e;
|
||||
--accent: #3366bb;
|
||||
--accent-strong: #24508f;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c01818;
|
||||
--warning: #806000;
|
||||
--success: #2a7a2a;
|
||||
--field: #ffffff;
|
||||
--selected: #d0dcf2;
|
||||
--hover: #ececec;
|
||||
--shadow: rgba(0, 0, 0, .3);
|
||||
--folder-bg: #e6e6e6;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="beos"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1c1c1c;
|
||||
--panel: #262626;
|
||||
--panel-2: #2e2e2e;
|
||||
--panel-3: #383838;
|
||||
--sidebar: #222222;
|
||||
--line: #3e3e3e;
|
||||
--line-strong: #5e5e5e;
|
||||
--text: #eeeeee;
|
||||
--muted: #b8b8b8;
|
||||
--subtle: #8c8c8c;
|
||||
--accent: #5f8fe0;
|
||||
--accent-strong: #86aaf0;
|
||||
--accent-ink: #08142a;
|
||||
--danger: #ff6b6b;
|
||||
--warning: #ffcb2d;
|
||||
--success: #6fcf6f;
|
||||
--field: #1c1c1c;
|
||||
--selected: #2e4470;
|
||||
--hover: #303030;
|
||||
--shadow: rgba(0, 0, 0, .5);
|
||||
--folder-bg: #222222;
|
||||
--message-bg: #262626;
|
||||
--reader-bg: #262626;
|
||||
}
|
||||
|
||||
+181
-50
@@ -7,9 +7,8 @@
|
||||
* 3. the structural tokens below, consumed only when the user selects
|
||||
* "Immersive" style depth (58-skin-immersive.css).
|
||||
*
|
||||
* Gradients are opt-in through their own Appearance switch (skin_gradients):
|
||||
* every gradient token has a flat value here and its gradient form only in a
|
||||
* block scoped to html[data-skin-gradients="true"].
|
||||
* Tokens here are always flat. Gradients live in 59-skin-gradients.css and
|
||||
* only apply with the separate Appearance switch (skin_gradients).
|
||||
*
|
||||
* Generated from one table; keep every style listed here.
|
||||
*/
|
||||
@@ -106,15 +105,6 @@ html[data-interface-skin="roundcube-classic"][data-theme="dark"] {
|
||||
--skin-appbar-bg: #313131;
|
||||
--skin-appbar-fg: #f2f2f2;
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="roundcube-classic"] {
|
||||
--skin-primary-bg: linear-gradient(color-mix(in srgb, var(--accent) 78%, white), var(--accent));
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="light"] {
|
||||
--skin-appbar-bg: linear-gradient(#f4f4f4, #cccccc);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="dark"] {
|
||||
--skin-appbar-bg: linear-gradient(#3a3a3a, #292929);
|
||||
}
|
||||
|
||||
html[data-interface-skin="roundcube-larry"] {
|
||||
--skin-radius-control: 3px;
|
||||
@@ -553,12 +543,6 @@ html[data-interface-skin="dracula"][data-theme="dark"] {
|
||||
--skin-primary-bg: var(--accent);
|
||||
--skin-primary-fg: #1d1530;
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="light"] {
|
||||
--skin-primary-bg: linear-gradient(135deg, #644ac9, #a3144d);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="dark"] {
|
||||
--skin-primary-bg: linear-gradient(135deg, #bd93f9, #ff79c6);
|
||||
}
|
||||
|
||||
html[data-interface-skin="catppuccin"] {
|
||||
--skin-row-inset: 6px;
|
||||
@@ -623,14 +607,6 @@ html[data-interface-skin="tokyo-night"][data-theme="dark"] {
|
||||
--skin-marker-color: #bb9af7;
|
||||
--skin-primary-bg: var(--accent);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="light"] {
|
||||
--skin-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(46, 125, 233, .14), transparent 60%), var(--bg);
|
||||
--skin-primary-bg: linear-gradient(135deg, var(--accent), #9854f1);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="dark"] {
|
||||
--skin-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(122, 162, 247, .12), transparent 60%), var(--bg);
|
||||
--skin-primary-bg: linear-gradient(135deg, var(--accent), #bb9af7);
|
||||
}
|
||||
|
||||
html[data-interface-skin="rose-pine"] {
|
||||
--skin-row-inset: 6px;
|
||||
@@ -684,14 +660,6 @@ html[data-interface-skin="synthwave"][data-theme="dark"] {
|
||||
--skin-primary-shadow: 0 0 14px rgba(255, 126, 219, .55);
|
||||
--skin-marker-color: #72f1b8;
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="light"] {
|
||||
--skin-backdrop: linear-gradient(180deg, #f3e9ff 0%, #ffe3f3 100%);
|
||||
--skin-primary-bg: linear-gradient(90deg, #d6249f, #7c3aed);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] {
|
||||
--skin-backdrop: linear-gradient(180deg, #1a1029 0%, #241038 55%, #3a1145 100%);
|
||||
--skin-primary-bg: linear-gradient(90deg, #ff7edb, #f97e72);
|
||||
}
|
||||
|
||||
html[data-interface-skin="paper"] {
|
||||
--skin-shell-gap: 0px;
|
||||
@@ -739,9 +707,6 @@ html[data-interface-skin="terminal"][data-theme="dark"] {
|
||||
--skin-pane-shadow: 0 0 0 1px rgba(57, 255, 106, .06), 0 0 18px -8px rgba(57, 255, 106, .35);
|
||||
--skin-backdrop: var(--bg);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="terminal"][data-theme="dark"] {
|
||||
--skin-backdrop: repeating-linear-gradient(0deg, rgba(57, 255, 106, .035) 0 1px, transparent 1px 3px), var(--bg);
|
||||
}
|
||||
|
||||
html[data-interface-skin="aqua"] {
|
||||
--skin-row-inset: 6px;
|
||||
@@ -776,15 +741,6 @@ html[data-interface-skin="aqua"][data-theme="dark"] {
|
||||
--skin-backdrop: var(--bg);
|
||||
--skin-pane-border: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="aqua"] {
|
||||
--skin-primary-bg: linear-gradient(180deg, color-mix(in srgb, var(--accent) 72%, white), var(--accent));
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="light"] {
|
||||
--skin-backdrop: radial-gradient(900px 520px at 8% 0%, #bfe0ff 0%, transparent 60%), radial-gradient(900px 620px at 100% 100%, #e8d6ff 0%, transparent 55%), linear-gradient(135deg, #e6eef8, #f4f0fb);
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="dark"] {
|
||||
--skin-backdrop: radial-gradient(900px 520px at 8% 0%, rgba(74, 163, 255, .28), transparent 60%), radial-gradient(900px 620px at 100% 100%, rgba(175, 82, 222, .22), transparent 55%), #0b1220;
|
||||
}
|
||||
|
||||
html[data-interface-skin="linear"] {
|
||||
--skin-font: "Inter Variable", Inter, var(--font-sans);
|
||||
@@ -888,9 +844,184 @@ html[data-interface-skin="retro"][data-theme="dark"] {
|
||||
--skin-appbar-bg: #1c2f63;
|
||||
--skin-selected-bg: #2b3f6b;
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="light"] {
|
||||
--skin-appbar-bg: linear-gradient(90deg, #000080, #1084d0);
|
||||
|
||||
html[data-interface-skin="winxp"] {
|
||||
--skin-font: Tahoma, "Segoe UI", Verdana, sans-serif;
|
||||
--skin-heading-weight: 700;
|
||||
--skin-heading-tracking: 0;
|
||||
--skin-radius-control: 3px;
|
||||
--skin-radius-panel: 8px;
|
||||
--skin-radius-row: 0px;
|
||||
--skin-radius-avatar: 3px;
|
||||
--skin-shell-gap: 6px;
|
||||
--skin-shell-padding: 0 6px 6px;
|
||||
--skin-pane-border: 1px solid var(--line-strong);
|
||||
--skin-pane-shadow: none;
|
||||
--skin-appbar-fg: #ffffff;
|
||||
--skin-search-bg: #ffffff;
|
||||
--skin-search-fg: #000000;
|
||||
--skin-search-border: #7f9db9;
|
||||
--skin-selected-fg: #ffffff;
|
||||
--skin-selected-muted: #dfe8fa;
|
||||
--skin-compose-bg: #3c9d29;
|
||||
--skin-compose-fg: #ffffff;
|
||||
--skin-radius-compose: 4px 14px 14px 4px;
|
||||
--skin-section-transform: none;
|
||||
--skin-section-tracking: 0;
|
||||
}
|
||||
html[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="dark"] {
|
||||
--skin-appbar-bg: linear-gradient(90deg, #1c2f63, #3a6fd8);
|
||||
html[data-interface-skin="winxp"][data-theme="light"] {
|
||||
--skin-appbar-bg: #245edb;
|
||||
--skin-sidebar-bg: #c7d7f7;
|
||||
--skin-selected-bg: #316ac5;
|
||||
}
|
||||
html[data-interface-skin="winxp"][data-theme="dark"] {
|
||||
--skin-appbar-bg: #1c3f8f;
|
||||
--skin-selected-bg: #2c4370;
|
||||
}
|
||||
|
||||
html[data-interface-skin="osx"] {
|
||||
--skin-shell-gap: 0px;
|
||||
--skin-shell-padding: 0;
|
||||
--skin-pane-border: 0 solid transparent;
|
||||
--skin-pane-divider: 1px solid var(--line);
|
||||
--skin-pane-shadow: none;
|
||||
--skin-radius-panel: 0px;
|
||||
--skin-font: "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--skin-heading-weight: 700;
|
||||
--skin-heading-tracking: 0;
|
||||
--skin-radius-control: 5px;
|
||||
--skin-radius-button: 999px;
|
||||
--skin-radius-row: 0px;
|
||||
--skin-appbar-border: var(--line-strong);
|
||||
--skin-search-radius: 999px;
|
||||
--skin-selected-bg: #3875d7;
|
||||
--skin-selected-fg: #ffffff;
|
||||
--skin-selected-muted: #dbe7fa;
|
||||
--skin-folder-active-bg: #3875d7;
|
||||
--skin-folder-active-fg: #ffffff;
|
||||
--skin-section-tracking: .04em;
|
||||
}
|
||||
html[data-interface-skin="osx"][data-theme="light"] {
|
||||
--skin-appbar-bg: #d4d4d4;
|
||||
}
|
||||
html[data-interface-skin="osx"][data-theme="dark"] {
|
||||
--skin-appbar-bg: #2f3035;
|
||||
}
|
||||
|
||||
html[data-interface-skin="ios6"] {
|
||||
--skin-shell-gap: 0px;
|
||||
--skin-shell-padding: 0;
|
||||
--skin-pane-border: 0 solid transparent;
|
||||
--skin-pane-divider: 1px solid var(--line);
|
||||
--skin-pane-shadow: none;
|
||||
--skin-radius-panel: 0px;
|
||||
--skin-font: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--skin-heading-weight: 700;
|
||||
--skin-heading-tracking: 0;
|
||||
--skin-radius-control: 5px;
|
||||
--skin-radius-row: 0px;
|
||||
--skin-radius-avatar: 4px;
|
||||
--skin-appbar-fg: #ffffff;
|
||||
--skin-search-bg: #ffffff;
|
||||
--skin-search-fg: #000000;
|
||||
--skin-search-border: transparent;
|
||||
--skin-search-radius: 999px;
|
||||
--skin-selected-bg: #1a7ae6;
|
||||
--skin-selected-fg: #ffffff;
|
||||
--skin-selected-muted: #dbeaff;
|
||||
--skin-section-transform: none;
|
||||
--skin-section-tracking: 0;
|
||||
}
|
||||
html[data-interface-skin="ios6"][data-theme="light"] {
|
||||
--skin-appbar-bg: #6d84a2;
|
||||
}
|
||||
html[data-interface-skin="ios6"][data-theme="dark"] {
|
||||
--skin-appbar-bg: #2a313b;
|
||||
}
|
||||
|
||||
html[data-interface-skin="win7"] {
|
||||
--skin-font: "Segoe UI", Tahoma, sans-serif;
|
||||
--skin-heading-weight: 600;
|
||||
--skin-heading-tracking: 0;
|
||||
--skin-radius-control: 3px;
|
||||
--skin-radius-panel: 8px;
|
||||
--skin-radius-row: 3px;
|
||||
--skin-appbar-bg: transparent;
|
||||
--skin-glass: blur(14px) saturate(1.4);
|
||||
--skin-pane-shadow: 0 8px 26px -10px rgba(0, 0, 0, .45);
|
||||
--skin-search-bg: #ffffff;
|
||||
--skin-search-fg: #000000;
|
||||
--skin-search-border: transparent;
|
||||
--skin-folder-active-fg: var(--text);
|
||||
--skin-section-transform: none;
|
||||
--skin-section-tracking: 0;
|
||||
}
|
||||
html[data-interface-skin="win7"][data-theme="light"] {
|
||||
--skin-backdrop: #3a7bd5;
|
||||
--skin-appbar-fg: #ffffff;
|
||||
--skin-folder-active-bg: #cce8ff;
|
||||
}
|
||||
html[data-interface-skin="win7"][data-theme="dark"] {
|
||||
--skin-backdrop: #0b1a2e;
|
||||
--skin-folder-active-bg: #24466e;
|
||||
}
|
||||
|
||||
html[data-interface-skin="frutiger-aero"] {
|
||||
--skin-row-inset: 6px;
|
||||
--skin-row-gap: 2px;
|
||||
--skin-row-divider: transparent;
|
||||
--skin-font: "Segoe UI", "Trebuchet MS", sans-serif;
|
||||
--skin-heading-weight: 600;
|
||||
--skin-radius-control: 12px;
|
||||
--skin-radius-button: 999px;
|
||||
--skin-radius-panel: 18px;
|
||||
--skin-radius-row: 12px;
|
||||
--skin-shell-gap: 12px;
|
||||
--skin-shell-padding: 0 12px 12px;
|
||||
--skin-glass: blur(16px) saturate(1.6);
|
||||
--skin-pane-bg: color-mix(in srgb, var(--panel) 76%, transparent);
|
||||
--skin-reader-bg: color-mix(in srgb, var(--panel) 86%, transparent);
|
||||
--skin-sidebar-bg: color-mix(in srgb, var(--sidebar) 62%, transparent);
|
||||
--skin-pane-shadow: 0 14px 34px -16px var(--shadow);
|
||||
--skin-appbar-bg: transparent;
|
||||
--skin-search-radius: 999px;
|
||||
--skin-section-transform: none;
|
||||
--skin-section-tracking: .02em;
|
||||
}
|
||||
html[data-interface-skin="frutiger-aero"][data-theme="light"] {
|
||||
--skin-backdrop: #bfe9ff;
|
||||
--skin-pane-border: 1px solid rgba(255, 255, 255, .75);
|
||||
}
|
||||
html[data-interface-skin="frutiger-aero"][data-theme="dark"] {
|
||||
--skin-backdrop: #04131f;
|
||||
--skin-pane-border: 1px solid rgba(120, 220, 255, .14);
|
||||
}
|
||||
|
||||
html[data-interface-skin="beos"] {
|
||||
--skin-font: "Swis721 BT", Verdana, "DejaVu Sans", sans-serif;
|
||||
--skin-heading-weight: 700;
|
||||
--skin-heading-tracking: 0;
|
||||
--skin-radius-control: 2px;
|
||||
--skin-radius-panel: 0px;
|
||||
--skin-radius-row: 0px;
|
||||
--skin-radius-avatar: 0px;
|
||||
--skin-shell-gap: 6px;
|
||||
--skin-shell-padding: 0 6px 6px;
|
||||
--skin-pane-border: 1px solid var(--line-strong);
|
||||
--skin-pane-shadow: 2px 2px 0 rgba(0, 0, 0, .25);
|
||||
--skin-appbar-fg: #000000;
|
||||
--skin-search-bg: #ffffff;
|
||||
--skin-search-fg: #000000;
|
||||
--skin-search-border: #7a7a7a;
|
||||
--skin-search-radius: 0px;
|
||||
--skin-section-transform: none;
|
||||
--skin-section-tracking: 0;
|
||||
}
|
||||
html[data-interface-skin="beos"][data-theme="light"] {
|
||||
--skin-appbar-bg: #ffcb00;
|
||||
--skin-control-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #999999;
|
||||
}
|
||||
html[data-interface-skin="beos"][data-theme="dark"] {
|
||||
--skin-appbar-bg: #e0b000;
|
||||
--skin-control-shadow: inset 1px 1px 0 #555555, inset -1px -1px 0 #111111;
|
||||
}
|
||||
@@ -280,12 +280,6 @@
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
:root[data-skin-depth="immersive"][data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] .app-brand strong {
|
||||
background: linear-gradient(90deg, #ff7edb, #fede5d);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
:root[data-skin-depth="immersive"][data-theme] :is(.appbar, .folder-pane, .message-pane, .reader-pane) {
|
||||
@@ -293,15 +287,3 @@
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating panes are a little narrower than the flush layout, so drop the
|
||||
reader toolbar's button labels a bit earlier than the standard 640px. */
|
||||
@container reader-pane (max-width: 820px) {
|
||||
:root[data-skin-depth="immersive"][data-theme] .reader-toolbar .button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-skin-depth="immersive"][data-theme] .reader-toolbar .button {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* Style gradients (0.2.24): generated from one table.
|
||||
*
|
||||
* Active only with Appearance -> "Style gradients" on, at every style depth,
|
||||
* for every predefined style except the RustBox custom style. Gradients are
|
||||
* background-image layers over the colours chosen by the palette and depth;
|
||||
* the defaults are translucent overlays, signature styles override them.
|
||||
*/
|
||||
|
||||
:root[data-skin-gradients="true"] {
|
||||
--grad-backdrop: radial-gradient(1100px 560px at 92% -12%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 62%);
|
||||
--grad-appbar: linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 55%, rgba(0, 0, 0, .06));
|
||||
--grad-toolbar: linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent);
|
||||
--grad-sidebar: linear-gradient(180deg, transparent 35%, color-mix(in srgb, var(--accent) 10%, transparent));
|
||||
--grad-selected: linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent), transparent 85%);
|
||||
--grad-primary: linear-gradient(180deg, rgba(255, 255, 255, .26), rgba(255, 255, 255, 0) 52%, rgba(0, 0, 0, .10));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme] {
|
||||
--grad-primary: linear-gradient(color-mix(in srgb, var(--accent) 78%, white), var(--accent));
|
||||
--grad-selected: linear-gradient(180deg, rgba(255, 255, 255, .22), rgba(0, 0, 0, .08));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(#3a3a3a, #292929);
|
||||
--grad-toolbar: linear-gradient(#3a3a3a, #292929);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(#f4f4f4, #cccccc);
|
||||
--grad-toolbar: linear-gradient(#f4f4f4, #cccccc);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, #2c89a4, #005d76);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(180deg, #24292c, #121517);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(180deg, #505050, #2c2c2c);
|
||||
--grad-toolbar: linear-gradient(180deg, #ffffff, #e6e6e6);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-elastic"][data-theme] {
|
||||
--grad-toolbar: linear-gradient(180deg, rgba(255, 255, 255, .5), transparent);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="gmail"][data-theme] {
|
||||
--grad-backdrop: radial-gradient(900px 480px at 0% 0%, color-mix(in srgb, #4285f4 14%, transparent), transparent 60%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="outlook"][data-theme] {
|
||||
--grad-appbar: linear-gradient(90deg, rgba(255, 255, 255, .12), rgba(255, 255, 255, 0) 60%);
|
||||
--grad-primary: linear-gradient(135deg, color-mix(in srgb, var(--accent) 80%, white), var(--accent));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="yahoo"][data-theme] {
|
||||
--grad-primary: linear-gradient(135deg, #7e1fff, #5f01d1);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="hey"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, color-mix(in srgb, var(--accent) 82%, white), var(--accent));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="nord"][data-theme] {
|
||||
--grad-backdrop: radial-gradient(1000px 520px at 100% 0%, rgba(136, 192, 208, .18), transparent 60%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="github"][data-theme="dark"] {
|
||||
--grad-primary: linear-gradient(180deg, #2ea043, #238636);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="github"][data-theme="light"] {
|
||||
--grad-primary: linear-gradient(180deg, #2c974b, #1f883d);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="discord"][data-theme] {
|
||||
--grad-primary: linear-gradient(135deg, #7289da, #5865f2);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="slack"][data-skin-depth="immersive"][data-theme="dark"] {
|
||||
--grad-sidebar: linear-gradient(180deg, #1f1b24, #19171d);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="slack"][data-skin-depth="immersive"][data-theme="light"] {
|
||||
--grad-sidebar: linear-gradient(180deg, #4a154b, #3f0e40 60%, #350d36);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="reddit"][data-theme] {
|
||||
--grad-primary: linear-gradient(135deg, #ff6a2b, #ff4500);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="dark"] {
|
||||
--grad-primary: linear-gradient(135deg, #bd93f9, #ff79c6);
|
||||
--grad-backdrop: radial-gradient(1000px 520px at 100% 0%, rgba(255, 121, 198, .14), transparent 60%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="light"] {
|
||||
--grad-primary: linear-gradient(135deg, #644ac9, #a3144d);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="catppuccin"][data-theme] {
|
||||
--grad-primary: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #f5c2e7));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="solarized"][data-theme] {
|
||||
--grad-selected: linear-gradient(90deg, rgba(181, 137, 0, .18), transparent 80%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="gruvbox"][data-theme] {
|
||||
--grad-selected: linear-gradient(90deg, rgba(250, 189, 47, .2), transparent 80%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="dark"] {
|
||||
--grad-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(122, 162, 247, .16), transparent 60%);
|
||||
--grad-primary: linear-gradient(135deg, #7aa2f7, #bb9af7);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="light"] {
|
||||
--grad-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(46, 125, 233, .16), transparent 60%);
|
||||
--grad-primary: linear-gradient(135deg, #2e7de9, #9854f1);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="rose-pine"][data-theme] {
|
||||
--grad-primary: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #c4a7e7));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="everforest"][data-theme] {
|
||||
--grad-sidebar: linear-gradient(180deg, transparent, rgba(131, 192, 146, .12));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] {
|
||||
--grad-backdrop: linear-gradient(180deg, #1a1029 0%, #241038 55%, #3a1145 100%);
|
||||
--grad-appbar: linear-gradient(90deg, rgba(255, 126, 219, .16), rgba(114, 241, 184, .08));
|
||||
--grad-primary: linear-gradient(90deg, #ff7edb, #f97e72);
|
||||
--grad-selected: linear-gradient(90deg, rgba(255, 126, 219, .28), rgba(114, 241, 184, .06));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="light"] {
|
||||
--grad-backdrop: linear-gradient(180deg, #f3e9ff 0%, #ffe3f3 100%);
|
||||
--grad-primary: linear-gradient(90deg, #d6249f, #7c3aed);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="paper"][data-theme] {
|
||||
--grad-toolbar: linear-gradient(180deg, rgba(255, 255, 255, .35), transparent);
|
||||
--grad-backdrop: none;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="terminal"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, color-mix(in srgb, var(--accent) 18%, transparent), transparent);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="terminal"][data-theme="dark"] {
|
||||
--grad-backdrop: repeating-linear-gradient(0deg, rgba(57, 255, 106, .035) 0 1px, transparent 1px 3px);
|
||||
--grad-sidebar: repeating-linear-gradient(0deg, rgba(57, 255, 106, .025) 0 1px, transparent 1px 3px);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, color-mix(in srgb, var(--accent) 72%, white), var(--accent));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="dark"] {
|
||||
--grad-backdrop: radial-gradient(900px 520px at 8% 0%, rgba(74, 163, 255, .28), transparent 60%), radial-gradient(900px 620px at 100% 100%, rgba(175, 82, 222, .22), transparent 55%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="light"] {
|
||||
--grad-backdrop: radial-gradient(900px 520px at 8% 0%, #bfe0ff 0%, transparent 60%), radial-gradient(900px 620px at 100% 100%, #e8d6ff 0%, transparent 55%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="linear"][data-theme] {
|
||||
--grad-backdrop: radial-gradient(900px 480px at 50% -20%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 65%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="material"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme] {
|
||||
--grad-backdrop: none;
|
||||
--grad-toolbar: none;
|
||||
--grad-sidebar: none;
|
||||
--grad-primary: none;
|
||||
--grad-selected: none;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(90deg, #1c2f63, #3a6fd8);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(90deg, #000080, #1084d0);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, #78c46b 0%, #3c9d29 48%, #2f8a1f 52%, #3a9a2a 100%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(180deg, #3a5fae, #1c3f8f 45%, #142c5c);
|
||||
--grad-toolbar: linear-gradient(180deg, #2c3140, #232731);
|
||||
--grad-sidebar: linear-gradient(180deg, #22304d, #1a2238);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(180deg, #0058ee 0%, #3a93ff 4%, #288eff 6%, #127dff 8%, #036ffc 10%, #0262ee 14%, #0057e5 20%, #0054e3 24%, #0055eb 56%, #005bf5 66%, #026afe 76%, #0062ef 86%, #0052d6 92%, #0040ab 94%, #003092 100%);
|
||||
--grad-toolbar: linear-gradient(180deg, #fefefe, #ece9d8);
|
||||
--grad-sidebar: linear-gradient(180deg, #d4e0fa, #a9bdee);
|
||||
--grad-backdrop: linear-gradient(180deg, #5a8ee8 0%, #3c6fd6 40%, #6fb04a 100%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-skin-depth="immersive"][data-theme] {
|
||||
--grad-selected: linear-gradient(180deg, #4a85e0, #2456c4);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, #a8d1ff 0%, #5ba2f5 46%, #2b7fe0 50%, #4ea5ff 100%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(180deg, #3d3e43, #26272b);
|
||||
--grad-toolbar: repeating-linear-gradient(0deg, rgba(255, 255, 255, .03) 0 2px, transparent 2px 4px);
|
||||
--grad-backdrop: repeating-linear-gradient(0deg, rgba(255, 255, 255, .025) 0 2px, transparent 2px 4px);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(180deg, #e8e8e8, #bdbdbd);
|
||||
--grad-toolbar: repeating-linear-gradient(0deg, rgba(255, 255, 255, .6) 0 2px, rgba(0, 0, 0, .035) 2px 4px);
|
||||
--grad-backdrop: repeating-linear-gradient(0deg, #f2f2f2 0 2px, #e4e4e4 2px 4px);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-skin-depth="immersive"][data-theme] {
|
||||
--grad-selected: linear-gradient(180deg, #6ca3ee, #3875d7);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] {
|
||||
--grad-primary: linear-gradient(180deg, #7fb2f0 0%, #4a8ae8 50%, #3b7ddf 51%, #2f6fd3 100%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(180deg, #4a5463 0%, #2f3640 50%, #262c35 51%, #1f242c 100%);
|
||||
--grad-toolbar: linear-gradient(180deg, #323943, #262c34);
|
||||
--grad-backdrop: repeating-linear-gradient(45deg, rgba(255, 255, 255, .025) 0 2px, transparent 2px 4px), repeating-linear-gradient(-45deg, rgba(0, 0, 0, .25) 0 2px, transparent 2px 4px);
|
||||
--grad-sidebar: repeating-linear-gradient(45deg, rgba(255, 255, 255, .02) 0 2px, transparent 2px 4px), repeating-linear-gradient(-45deg, rgba(0, 0, 0, .2) 0 2px, transparent 2px 4px);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(180deg, #b0bccd 0%, #889bb3 50%, #8195af 51%, #6d84a2 100%);
|
||||
--grad-toolbar: linear-gradient(180deg, #f3f5f8, #d6dce4);
|
||||
--grad-backdrop: repeating-linear-gradient(90deg, #c5ccd4 0 5px, #cbd2d8 5px 7px);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-skin-depth="immersive"][data-theme] {
|
||||
--grad-selected: linear-gradient(180deg, #058cf5, #015fe6);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme] {
|
||||
--grad-appbar: linear-gradient(180deg, rgba(255, 255, 255, .45), rgba(255, 255, 255, .12) 50%, rgba(255, 255, 255, 0) 51%, rgba(255, 255, 255, .16));
|
||||
--grad-primary: linear-gradient(180deg, #8fc6ff 0%, #4c9ae8 50%, #2f7fd6 51%, #3e94ee 100%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme="dark"] {
|
||||
--grad-backdrop: radial-gradient(1200px 700px at 30% 110%, rgba(64, 160, 255, .35) 0%, transparent 55%), linear-gradient(160deg, #0a2347, #123a6e 50%, #06162e);
|
||||
--grad-toolbar: linear-gradient(180deg, #22304a, #1a2537);
|
||||
--grad-selected: linear-gradient(180deg, #2b4d78, #1f3b5e);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme="light"] {
|
||||
--grad-backdrop: radial-gradient(1200px 700px at 30% 110%, #7fd1ff 0%, transparent 55%), linear-gradient(160deg, #1c5db4 0%, #3a8ee6 45%, #0b3d8c 100%);
|
||||
--grad-toolbar: linear-gradient(180deg, #ffffff, #e9f1fb);
|
||||
--grad-selected: linear-gradient(180deg, #f1f8ff, #d5eaff);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme] {
|
||||
--grad-appbar: linear-gradient(180deg, rgba(255, 255, 255, .35), rgba(255, 255, 255, 0));
|
||||
--grad-primary: linear-gradient(180deg, #bff0ff 0%, #4cc4ff 48%, #0e9be0 52%, #34b9ff 100%);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="dark"] {
|
||||
--grad-backdrop: radial-gradient(900px 500px at 80% 0%, rgba(57, 192, 255, .35), transparent 60%), radial-gradient(800px 500px at 0% 100%, rgba(110, 227, 110, .18), transparent 55%), linear-gradient(180deg, #03101b, #06263a);
|
||||
--grad-selected: linear-gradient(180deg, #1b5a80, #134563);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="light"] {
|
||||
--grad-backdrop: radial-gradient(800px 400px at 15% 0%, rgba(255, 255, 255, .9), transparent 60%), radial-gradient(900px 500px at 100% 100%, #8be07a 0%, transparent 55%), linear-gradient(180deg, #5cc8ff 0%, #bfeaff 55%, #e9fbe9 100%);
|
||||
--grad-selected: linear-gradient(180deg, #e6f8ff, #bfe8ff);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="dark"] {
|
||||
--grad-appbar: linear-gradient(180deg, #f5cf3a, #d6a800);
|
||||
--grad-toolbar: linear-gradient(180deg, #363636, #262626);
|
||||
--grad-primary: linear-gradient(180deg, #4a4a4a, #2c2c2c);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="light"] {
|
||||
--grad-appbar: linear-gradient(180deg, #ffe066 0%, #ffcb00 60%, #e5b300 100%);
|
||||
--grad-toolbar: linear-gradient(180deg, #f4f4f4, #d0d0d0);
|
||||
--grad-primary: linear-gradient(180deg, #ffffff, #d6d6d6);
|
||||
}
|
||||
|
||||
/* Consumers ------------------------------------------------------------ */
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .app-view {
|
||||
background-image: var(--grad-backdrop);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .appbar {
|
||||
background-image: var(--grad-appbar);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .pane-toolbar,
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .dialog-header {
|
||||
background-image: var(--grad-toolbar);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .folder-pane {
|
||||
background-image: var(--grad-sidebar);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .folder-button.active,
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .message-row.active {
|
||||
background-image: var(--grad-selected);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .button.primary,
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .button.compose-button {
|
||||
background-image: var(--grad-primary);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] :is(.button.primary, .button.compose-button) {
|
||||
border-color: color-mix(in srgb, currentColor 18%, transparent);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] .appbar,
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="yahoo"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="github"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="discord"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="reddit"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
|
||||
color: #1d1530;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="light"] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
|
||||
color: #16161e;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="light"] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
|
||||
color: #2a0d23;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="light"] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme] .appbar,
|
||||
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] .appbar,
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="light"] :is(.button.primary, .button.compose-button) {
|
||||
color: #0a1d33;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] .appbar,
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
|
||||
color: #02121d;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="light"] :is(.button.primary, .button.compose-button) {
|
||||
color: #04304a;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme] .appbar,
|
||||
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="light"] :is(.button.primary, .button.compose-button) {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-skin-gradients="true"][data-skin-depth="immersive"][data-interface-skin="synthwave"][data-theme="dark"] .app-brand strong {
|
||||
background: linear-gradient(90deg, #ff7edb, #fede5d);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
@@ -8,4 +8,5 @@
|
||||
56-interface-styles.css
|
||||
57-skin-tokens.css
|
||||
58-skin-immersive.css
|
||||
59-skin-gradients.css
|
||||
60-responsive.css
|
||||
@@ -68,5 +68,11 @@
|
||||
"skin_depth_standard_note": "Colors plus the style's layout: density, row height, pane widths and type size.",
|
||||
"skin_depth_immersive_note": "Everything in Standard plus the style's shapes, typography, floating panes, app bar, selection markers and backdrop.",
|
||||
"skin_gradients": "Style gradients",
|
||||
"skin_gradients_note": "Allow predefined styles to use gradients (backdrops, app bars, buttons). When off, every style uses flat colors."
|
||||
"skin_gradients_note": "Adds each predefined style's gradients (app bar, toolbars, sidebar, selection, buttons, backdrop) at any style depth. Not used by the RustBox custom style.",
|
||||
"theme_schedule": "By time of day",
|
||||
"theme_light_from": "Light from",
|
||||
"theme_dark_from": "Dark from",
|
||||
"account_color": "Account color",
|
||||
"account_color_note": "A stripe on the app bar and a dot next to the address show which account is active.",
|
||||
"contrast_warning": "Low contrast"
|
||||
}
|
||||
+142
-19
@@ -125,12 +125,8 @@
|
||||
"Light": "Jasny",
|
||||
"System": "Systemowy",
|
||||
"Interface style": "Styl interfejsu",
|
||||
"RustBox (custom)": "RustBox (niestandardowy)",
|
||||
"RustBox (custom)": "RustBox (własny)",
|
||||
"Global default": "Domyślny globalny",
|
||||
"Gmail inspired": "Inspiracja Gmail",
|
||||
"Proton inspired": "Inspiracja Proton",
|
||||
"Zoho inspired": "Inspiracja Zoho",
|
||||
"Outlook inspired": "Inspiracja Outlook",
|
||||
"High contrast": "Wysoki kontrast",
|
||||
"RustBox is fully customizable: colors, spacing, panes and typography.": "RustBox można w pełni dostosować: kolory, odstępy, panele i typografię.",
|
||||
"Color preset": "Paleta kolorów",
|
||||
@@ -633,10 +629,6 @@
|
||||
"Upload failed": "Wysyłanie nie powiodło się",
|
||||
"Another upload is already in progress": "Inne wysyłanie jest już w toku",
|
||||
"Repository": "Repozytorium",
|
||||
"Fastmail inspired": "Inspirowany Fastmail",
|
||||
"Apple Mail inspired": "Inspirowany Apple Mail",
|
||||
"Yahoo Mail inspired": "Inspirowany Yahoo Mail",
|
||||
"HEY inspired": "Inspirowany HEY",
|
||||
"Maximum message size": "Maksymalny rozmiar wiadomości",
|
||||
"Per-file upload limit set with": "Limit pojedynczego pliku ustawiany przez",
|
||||
"Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.": "Twardy limit całej wiadomości RFC822/MIME wysyłanej do MTA, wraz z narzutem base64/MIME.",
|
||||
@@ -644,12 +636,6 @@
|
||||
"Estimated size. The exact RFC822/MIME size is checked by the server before SMTP delivery.": "Rozmiar szacowany. Dokładny rozmiar RFC822/MIME jest sprawdzany przez serwer przed wysłaniem przez SMTP.",
|
||||
"Estimated message size exceeds the configured limit": "Szacowany rozmiar wiadomości przekracza skonfigurowany limit",
|
||||
"The exact size will be checked before sending.": "Dokładny rozmiar zostanie sprawdzony przed wysłaniem.",
|
||||
"GitHub inspired": "Inspirowany GitHub",
|
||||
"GitLab inspired": "Inspirowany GitLab",
|
||||
"Discord inspired": "Inspirowany Discord",
|
||||
"Slack inspired": "Inspirowany Slack",
|
||||
"Reddit inspired": "Inspirowany Reddit",
|
||||
"Notion inspired": "Inspirowany Notion",
|
||||
"Hide fields": "Ukryj pola",
|
||||
"Show fields": "Pokaż pola",
|
||||
"Hide addressing fields": "Ukryj pola adresowe",
|
||||
@@ -658,13 +644,144 @@
|
||||
"Signature insertion": "Wstawianie podpisu",
|
||||
"Quietly when sending": "Cicho przy wysyłce",
|
||||
"Show in composer": "Pokaż w edytorze",
|
||||
"Linear inspired": "Inspirowany Linear",
|
||||
"Material You inspired": "Inspirowany Material You",
|
||||
"Aqua Glass": "Szkło Aqua",
|
||||
"Paper": "Papier",
|
||||
"Terminal": "Terminal",
|
||||
"Retro 95": "Retro 95",
|
||||
"Style depth": "Głębokość stylu"
|
||||
"Style depth": "Głębokość stylu",
|
||||
"Text on panels": "Tekst na panelach",
|
||||
"Text on the background": "Tekst na tle",
|
||||
"Text on the selection": "Tekst na zaznaczeniu",
|
||||
"Secondary text": "Tekst pomocniczy",
|
||||
"Accent on panels": "Akcent na panelach",
|
||||
"Button text on the accent": "Tekst przycisku na akcencie",
|
||||
"10 minutes": "10 minut",
|
||||
"2 hours": "2 godziny",
|
||||
"1 day before": "1 dzień wcześniej",
|
||||
"2 days before": "2 dni wcześniej",
|
||||
"1 week before": "Tydzień wcześniej",
|
||||
"Second reminder": "Drugie przypomnienie",
|
||||
"Calendar reminder for new events": "Przypomnienie dla nowych wydarzeń",
|
||||
"Second reminder for new events": "Drugie przypomnienie dla nowych wydarzeń",
|
||||
"All-day events are reminded at the start of your working day (default 09:00), e.g. one day before.": "Wydarzenia całodniowe przypominane są na początku dnia pracy (domyślnie 09:00), np. dzień wcześniej.",
|
||||
"Calendar reminder defaults saved": "Zapisano domyślne przypomnienia kalendarza",
|
||||
"Clean": "Czysty",
|
||||
"Minimal": "Minimalny",
|
||||
"Graphite": "Grafit",
|
||||
"RustBox & classic": "RustBox i klasyczne",
|
||||
"Inspired by mail apps": "Inspirowane pocztą",
|
||||
"Inspired by apps": "Inspirowane aplikacjami",
|
||||
"Editor themes": "Motywy edytorów",
|
||||
"Atmospheric": "Nastrojowe",
|
||||
"+ Add exception": "+ Dodaj wyjątek",
|
||||
"Actions": "Akcje",
|
||||
"Add label": "Dodaj etykietę",
|
||||
"Also apply to messages already in “{folder}”": "Zastosuj też do wiadomości już w „{folder}”",
|
||||
"Always move messages from this sender": "Zawsze przenoś wiadomości od tego nadawcy",
|
||||
"Always move messages from this sender…": "Zawsze przenoś wiadomości od tego nadawcy…",
|
||||
"Apply now": "Zastosuj teraz",
|
||||
"Apply rule to existing messages": "Zastosuj regułę do istniejących wiadomości",
|
||||
"Apply this rule to messages that are already in a folder": "Zastosuj tę regułę do wiadomości, które już są w folderze",
|
||||
"Apply to existing messages": "Zastosuj do istniejących wiadomości",
|
||||
"Apply to {count} messages": "Zastosuj (wiadomości: {count})",
|
||||
"Applying… {done}/{total}": "Stosowanie… {done}/{total}",
|
||||
"Cc address": "Adres DW",
|
||||
"To address": "Adres Do",
|
||||
"Change the rule visually; the live preview shows exactly what will be written to Sieve.": "Zmień regułę wizualnie; podgląd na żywo pokazuje dokładnie, co zostanie zapisane w Sieve.",
|
||||
"Check matches": "Sprawdź dopasowania",
|
||||
"Checking…": "Sprawdzanie…",
|
||||
"Choose the folder whose messages “{rule}” should be applied to.": "Wybierz folder, do którego wiadomości ma zostać zastosowana reguła „{rule}”.",
|
||||
"Continue": "Dalej",
|
||||
"Copy to": "Kopiuj do",
|
||||
"Move to": "Przenieś do",
|
||||
"Created {folders}": "Utworzono {folders}",
|
||||
"Discard the message": "Odrzuć wiadomość",
|
||||
"Edit filter": "Edytuj filtr",
|
||||
"Every additional condition needs a value": "Każdy dodatkowy warunek wymaga wartości",
|
||||
"Except if": "Z wyjątkiem gdy",
|
||||
"except if": "z wyjątkiem gdy",
|
||||
"Filter added to {script}": "Filtr dodany do {script}",
|
||||
"Filter validation failed": "Walidacja filtru nie powiodła się",
|
||||
"Flag mail from a VIP": "Oflaguj pocztę od VIP",
|
||||
"Folder does not exist": "Folder nie istnieje",
|
||||
"Folder name": "Nazwa folderu",
|
||||
"Forward a copy": "Przekaż kopię",
|
||||
"High priority": "Wysoki priorytet",
|
||||
"Size larger than": "Rozmiar większy niż",
|
||||
"Size smaller than": "Rozmiar mniejszy niż",
|
||||
"Join": "Połącz",
|
||||
"Keep in the inbox": "Zostaw w skrzynce",
|
||||
"Keep rule as is": "Zostaw regułę bez zmian",
|
||||
"Large attachments": "Duże załączniki",
|
||||
"List-ID": "List-ID",
|
||||
"Mail sent to an alias": "Poczta na alias",
|
||||
"ManageSieve unavailable": "ManageSieve niedostępny",
|
||||
"Move mail from a sender": "Przenoś pocztę od nadawcy",
|
||||
"Move to Trash": "Przenieś do kosza",
|
||||
"NOT": "NIE",
|
||||
"AND": "I",
|
||||
"OR": "LUB",
|
||||
"New folder": "Nowy folder",
|
||||
"New folder…": "Nowy folder…",
|
||||
"New mail from {sender} will be moved automatically to:": "Nowa poczta od {sender} będzie automatycznie przenoszona do:",
|
||||
"Newsletters to a folder": "Newslettery do folderu",
|
||||
"No action": "Brak akcji",
|
||||
"No action parsed": "Nie rozpoznano akcji",
|
||||
"No messages in “{folder}” match this rule": "Żadna wiadomość w „{folder}” nie pasuje do tej reguły",
|
||||
"None of this rule's actions can be applied to existing messages": "Żadnej akcji tej reguły nie da się zastosować do istniejących wiadomości",
|
||||
"Only the first {count} will be processed.": "Przetworzonych zostanie tylko pierwszych {count}.",
|
||||
"Pick a common rule and fill in the blanks.": "Wybierz typową regułę i uzupełnij pola.",
|
||||
"Redirect a copy to": "Przekieruj kopię do",
|
||||
"Redirect to": "Przekieruj do",
|
||||
"Remove": "Usuń",
|
||||
"Rule applied to {count} messages": "Reguła zastosowana (wiadomości: {count})",
|
||||
"Rule value is required": "Wartość reguły jest wymagana",
|
||||
"Sieve only runs on new mail. Check which messages already in a folder match and apply the actions to them now.": "Sieve działa tylko na nowej poczcie. Sprawdź, które wiadomości w folderze pasują, i zastosuj do nich akcje teraz.",
|
||||
"Size must look like 500K, 2M or 1G": "Rozmiar podaj jak 500K, 2M lub 1G",
|
||||
"Skipped for existing messages": "Pominięte dla istniejących wiadomości",
|
||||
"Start from a template": "Zacznij od szablonu",
|
||||
"Stop processing more rules": "Zatrzymaj przetwarzanie kolejnych reguł",
|
||||
"Subject contains words": "Temat zawiera słowa",
|
||||
"The rule changed — check matches again.": "Reguła się zmieniła — sprawdź dopasowania ponownie.",
|
||||
"The rule is skipped when any exception matches.": "Reguła jest pomijana, gdy pasuje którykolwiek wyjątek.",
|
||||
"The rule moves mail to {folders}, which does not exist yet. Create it now?": "Reguła przenosi pocztę do {folders}, który jeszcze nie istnieje. Utworzyć go teraz?",
|
||||
"This message has no sender address": "Ta wiadomość nie ma adresu nadawcy",
|
||||
"This rule uses Sieve syntax that cannot be applied to existing messages": "Ta reguła używa składni Sieve, której nie da się zastosować do istniejących wiadomości",
|
||||
"Unsupported action": "Nieobsługiwana akcja",
|
||||
"Work": "Praca",
|
||||
"a value is required": "wymagana jest wartość",
|
||||
"choose a condition": "wybierz warunek",
|
||||
"custom header name is invalid": "nieprawidłowa nazwa nagłówka",
|
||||
"target folder is required": "wymagany jest folder docelowy",
|
||||
"redirect address is required": "wymagany jest adres przekierowania",
|
||||
"choose an existing label": "wybierz istniejącą etykietę",
|
||||
"IMAP flag is required": "wymagana jest flaga IMAP",
|
||||
"IMAP flags are required": "wymagane są flagi IMAP",
|
||||
"IMAP flag(s)": "Flaga(i) IMAP",
|
||||
"Additional condition": "Dodatkowy warunek",
|
||||
"Exception": "Wyjątek",
|
||||
"Header": "Nagłówek",
|
||||
"optional": "opcjonalnie",
|
||||
"value": "wartość",
|
||||
"set flags": "ustaw flagi",
|
||||
"{count} messages in “{folder}” match this rule.": "Wiadomości w „{folder}” pasujące do tej reguły: {count}.",
|
||||
"{count} more could not be checked exactly and will be left untouched.": "Pominięte, bo nie dało się ich sprawdzić dokładnie: {count}.",
|
||||
"{count} operations failed": "nieudane operacje: {count}",
|
||||
"Server does not advertise": "Serwer nie ogłasza",
|
||||
"Requires": "Wymaga",
|
||||
"No optional extension required": "Nie wymaga opcjonalnych rozszerzeń",
|
||||
"ON": "WŁ.",
|
||||
"OFF": "WYŁ.",
|
||||
"Edit": "Edytuj",
|
||||
"Disable": "Wyłącz",
|
||||
"Enable": "Włącz",
|
||||
"Uses syntax not fully supported by the visual editor; source is preserved unchanged.": "Używa składni nie w pełni obsługiwanej przez edytor wizualny; źródło pozostaje bez zmian.",
|
||||
"Snooze": "Odłóż",
|
||||
"Sender": "Nadawca",
|
||||
"More": "Więcej",
|
||||
"Snooze…": "Odłóż…",
|
||||
"View snoozed messages…": "Pokaż odłożone wiadomości…",
|
||||
"Open full screen": "Otwórz na pełnym ekranie"
|
||||
},
|
||||
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję presetu poniżej, aby przy pierwszej zmianie utworzyć jego edytowalną kopię.",
|
||||
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
||||
@@ -687,5 +804,11 @@
|
||||
"skin_depth_standard_note": "Kolory oraz układ stylu: gęstość, wysokość wierszy, szerokości paneli i rozmiar tekstu.",
|
||||
"skin_depth_immersive_note": "Wszystko ze standardowej oraz kształty, typografia, pływające panele, pasek aplikacji, znaczniki zaznaczenia i tło stylu.",
|
||||
"skin_gradients": "Gradienty w stylach",
|
||||
"skin_gradients_note": "Pozwala gotowym stylom używać gradientów (tła, paski aplikacji, przyciski). Po wyłączeniu każdy styl używa jednolitych kolorów."
|
||||
"skin_gradients_note": "Dodaje gradienty gotowego stylu (pasek aplikacji, paski narzędzi, panel boczny, zaznaczenie, przyciski, tło) przy każdej głębokości stylu. Nie dotyczy własnego stylu RustBox.",
|
||||
"theme_schedule": "Według pory dnia",
|
||||
"theme_light_from": "Jasny od",
|
||||
"theme_dark_from": "Ciemny od",
|
||||
"account_color": "Kolor konta",
|
||||
"account_color_note": "Pasek u góry aplikacji i kropka przy adresie pokazują, które konto jest aktywne.",
|
||||
"contrast_warning": "Niski kontrast"
|
||||
}
|
||||
+117
-40
@@ -629,46 +629,80 @@
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
<option value="schedule" data-i18n="theme_schedule">By time of day</option>
|
||||
</select></label>
|
||||
<div id="pref-theme-schedule-row" class="theme-schedule-row hidden">
|
||||
<label><span data-i18n="theme_light_from">Light from</span><input id="pref-theme-light-from" type="time"
|
||||
value="07:00" step="60"></label>
|
||||
<label><span data-i18n="theme_dark_from">Dark from</span><input id="pref-theme-dark-from" type="time"
|
||||
value="19:00" step="60"></label>
|
||||
</div>
|
||||
<div id="pref-account-color-row" class="account-color-row"><span data-i18n="account_color">Account
|
||||
color</span>
|
||||
<div class="color-input"><input id="pref-account-color-enabled" type="checkbox"
|
||||
aria-label="Show account color"><input id="pref-account-color" type="color" value="#4d75e6"
|
||||
aria-label="Account color"></div><small class="muted" data-i18n="account_color_note">A stripe on the
|
||||
app bar and a dot next to the address show which account is active.</small>
|
||||
</div>
|
||||
<label><span>Interface style</span><select id="pref-interface-skin">
|
||||
<option value="default">Global default</option>
|
||||
<option value="rustbox">RustBox (custom)</option>
|
||||
<option value="roundcube-classic">Roundcube Classic</option>
|
||||
<option value="roundcube-larry">Roundcube Larry</option>
|
||||
<option value="roundcube-elastic">Roundcube Elastic</option>
|
||||
<option value="clean">Clean</option>
|
||||
<option value="gmail">Gmail inspired</option>
|
||||
<option value="proton">Proton inspired</option>
|
||||
<option value="zoho">Zoho inspired</option>
|
||||
<option value="outlook">Outlook inspired</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="graphite">Graphite</option>
|
||||
<option value="high-contrast">High contrast</option>
|
||||
<option value="fastmail">Fastmail inspired</option>
|
||||
<option value="apple-mail">Apple Mail inspired</option>
|
||||
<option value="yahoo">Yahoo Mail inspired</option>
|
||||
<option value="hey">HEY inspired</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="github">GitHub inspired</option>
|
||||
<option value="gitlab">GitLab inspired</option>
|
||||
<option value="discord">Discord inspired</option>
|
||||
<option value="slack">Slack inspired</option>
|
||||
<option value="reddit">Reddit inspired</option>
|
||||
<option value="notion">Notion inspired</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="catppuccin">Catppuccin</option>
|
||||
<option value="solarized">Solarized</option>
|
||||
<option value="gruvbox">Gruvbox</option>
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="rose-pine">Rosé Pine</option>
|
||||
<option value="everforest">Everforest</option>
|
||||
<option value="synthwave">Synthwave</option>
|
||||
<option value="paper">Paper</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
<option value="aqua">Aqua Glass</option>
|
||||
<option value="linear">Linear inspired</option>
|
||||
<option value="material">Material You inspired</option>
|
||||
<option value="retro">Retro 95</option>
|
||||
<optgroup label="RustBox & classic">
|
||||
<option value="rustbox">RustBox (custom)</option>
|
||||
<option value="clean">Clean</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="graphite">Graphite</option>
|
||||
<option value="high-contrast">High contrast</option>
|
||||
</optgroup>
|
||||
<optgroup label="Roundcube">
|
||||
<option value="roundcube-classic">Roundcube Classic</option>
|
||||
<option value="roundcube-larry">Roundcube Larry</option>
|
||||
<option value="roundcube-elastic">Roundcube Elastic</option>
|
||||
</optgroup>
|
||||
<optgroup label="Inspired by mail apps">
|
||||
<option value="gmail">Gmail</option>
|
||||
<option value="outlook">Outlook</option>
|
||||
<option value="apple-mail">Apple Mail</option>
|
||||
<option value="fastmail">Fastmail</option>
|
||||
<option value="proton">Proton</option>
|
||||
<option value="zoho">Zoho</option>
|
||||
<option value="yahoo">Yahoo</option>
|
||||
<option value="hey">HEY</option>
|
||||
</optgroup>
|
||||
<optgroup label="Inspired by apps">
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="reddit">Reddit</option>
|
||||
<option value="notion">Notion</option>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="material">Material You</option>
|
||||
</optgroup>
|
||||
<optgroup label="Editor themes">
|
||||
<option value="nord">Nord</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="catppuccin">Catppuccin</option>
|
||||
<option value="solarized">Solarized</option>
|
||||
<option value="gruvbox">Gruvbox</option>
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="rose-pine">Rosé Pine</option>
|
||||
<option value="everforest">Everforest</option>
|
||||
</optgroup>
|
||||
<optgroup label="Atmospheric">
|
||||
<option value="synthwave">Synthwave</option>
|
||||
<option value="paper">Paper</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
<option value="aqua">Aqua Glass</option>
|
||||
<option value="frutiger-aero">Frutiger Aero</option>
|
||||
</optgroup>
|
||||
<optgroup label="Retro">
|
||||
<option value="retro">Retro 95</option>
|
||||
<option value="winxp">Windows XP</option>
|
||||
<option value="osx">Mac OS X</option>
|
||||
<option value="ios6">iOS 6</option>
|
||||
<option value="win7">Windows 7</option>
|
||||
<option value="beos">BeOS</option>
|
||||
</optgroup>
|
||||
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
|
||||
spacing, panes and typography.</small></label>
|
||||
<label><span data-i18n="skin_depth">Style depth</span><select id="pref-skin-depth">
|
||||
@@ -679,8 +713,9 @@
|
||||
height, pane widths and type size.</small></label>
|
||||
<label id="pref-skin-gradients-row" class="checkbox-row"><input id="pref-skin-gradients"
|
||||
type="checkbox"><span data-i18n="skin_gradients">Style gradients</span><small class="muted"
|
||||
data-i18n="skin_gradients_note">Allow predefined styles to use gradients (backdrops, app bars,
|
||||
buttons). When off, every style uses flat colors.</small></label>
|
||||
data-i18n="skin_gradients_note">Adds each predefined style's gradients (app bar, toolbars,
|
||||
sidebar, selection, buttons, backdrop) at any style depth. Not used by the RustBox custom
|
||||
style.</small></label>
|
||||
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
|
||||
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow editing predefined style</span><small
|
||||
class="muted">When enabled, the first appearance change creates and saves a RustBox custom copy of the
|
||||
@@ -724,6 +759,7 @@
|
||||
<div class="color-input"><input id="pref-text-color" type="color" value="#edf0ec"><output
|
||||
id="pref-text-color-value">#edf0ec</output></div>
|
||||
</label>
|
||||
<p id="pref-contrast-warning" class="contrast-warning hidden" role="status" aria-live="polite"></p>
|
||||
<label><span data-i18n="density">Density</span><select id="pref-density">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
@@ -1102,6 +1138,15 @@
|
||||
<div class="settings-grid"><label><span>DND from</span><input id="notify-dnd-start" type="time"
|
||||
value="22:00"></label><label><span>DND until</span><input id="notify-dnd-end" type="time"
|
||||
value="07:00"></label></div>
|
||||
<div class="settings-grid calendar-reminder-defaults"><label><span>Calendar reminder for new
|
||||
events</span><select id="calendar-default-reminder">
|
||||
<option value="">None</option>
|
||||
</select></label><label><span>Second reminder for new events</span><select
|
||||
id="calendar-default-reminder-extra">
|
||||
<option value="">None</option>
|
||||
</select></label></div>
|
||||
<p class="muted calendar-reminder-note">All-day events are reminded at the start of your working day
|
||||
(default 09:00), e.g. one day before.</p>
|
||||
<div><span class="field-label">Folders</span>
|
||||
<div id="notify-folders" class="notification-folder-list"></div>
|
||||
</div>
|
||||
@@ -1442,6 +1487,13 @@
|
||||
<div class="sieve-rule-dialog-body">
|
||||
<div class="sieve-builder sieve-builder-modal">
|
||||
<div class="sieve-builder-body">
|
||||
<section class="sieve-builder-section sieve-templates-section">
|
||||
<div class="sieve-section-heading">
|
||||
<div><strong>Start from a template</strong><small class="muted">Pick a common rule and fill in the
|
||||
blanks.</small></div>
|
||||
</div>
|
||||
<div id="sieve-templates" class="sieve-templates"></div>
|
||||
</section>
|
||||
<section class="sieve-builder-section">
|
||||
<div class="sieve-section-heading">
|
||||
<div><strong>Rule</strong><small class="muted">Give it a readable name and decide whether it is
|
||||
@@ -1536,6 +1588,17 @@
|
||||
class="button quiet small" type="button">+ Add condition</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="sieve-builder-section">
|
||||
<div class="sieve-section-heading">
|
||||
<div><strong>Except if</strong><small class="muted">The rule is skipped when any exception
|
||||
matches.</small></div>
|
||||
</div>
|
||||
<div class="sieve-builder-grid">
|
||||
<div id="sieve-builder-exceptions" class="sieve-builder-dynamic-list sieve-builder-wide"></div>
|
||||
<div class="inline-actions sieve-builder-wide"><button id="sieve-builder-add-exception"
|
||||
class="button quiet small" type="button">+ Add exception</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="sieve-builder-section">
|
||||
<div class="sieve-section-heading">
|
||||
<div><strong>Then</strong><small class="muted">Actions run in the order shown below.</small></div>
|
||||
@@ -1554,7 +1617,7 @@
|
||||
</select></label>
|
||||
<label id="sieve-builder-target-wrap"><span id="sieve-builder-target-label">Folder</span><input
|
||||
id="sieve-builder-target" list="sieve-folder-options" placeholder="Archive"><datalist
|
||||
id="sieve-folder-options"></datalist></label>
|
||||
id="sieve-folder-options"></datalist><datalist id="sieve-tag-options"></datalist></label>
|
||||
<label><span>Second action</span><select id="sieve-builder-action-2">
|
||||
<option value="">None</option>
|
||||
<option value="move">Move to folder</option>
|
||||
@@ -1584,6 +1647,18 @@
|
||||
</div>
|
||||
<pre id="sieve-builder-preview-code" class="sieve-rule-preview-code"></pre>
|
||||
</section>
|
||||
<section class="sieve-builder-section sieve-apply-now" aria-live="polite">
|
||||
<div class="sieve-section-heading">
|
||||
<div><strong>Apply to existing messages</strong><small class="muted">Sieve only runs on new mail.
|
||||
Check which messages already in a folder match and apply the actions to them now.</small></div>
|
||||
</div>
|
||||
<div class="sieve-apply-row">
|
||||
<label><span>Folder</span><select id="sieve-apply-folder"></select></label>
|
||||
<button id="sieve-apply-check" class="button quiet" type="button">Check matches</button>
|
||||
<button id="sieve-apply-run" class="button" type="button" disabled>Apply now</button>
|
||||
</div>
|
||||
<div id="sieve-apply-result" class="sieve-apply-result muted"></div>
|
||||
</section>
|
||||
<div class="sieve-builder-footer">
|
||||
<div class="inline-actions"><button id="sieve-builder-append" class="button primary" type="button">Add
|
||||
rule</button><button id="sieve-builder-replace" class="button quiet" type="button" disabled>Update
|
||||
@@ -1838,6 +1913,8 @@
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="60">1 hour</option>
|
||||
<option value="1440">1 day</option>
|
||||
</select></label><label><span>Second reminder</span><select id="calendar-reminder-extra">
|
||||
<option value="">None</option>
|
||||
</select></label></div>
|
||||
<div class="settings-grid"><label><span>Organizer</span><input id="calendar-organizer"
|
||||
type="email"></label><label><span>Color</span><input id="calendar-color" type="color"
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
imapSyncPending: 0,
|
||||
imapSyncInventoryPending: 0,
|
||||
calendarReminderTimer: null,
|
||||
calendarSettings: null,
|
||||
composeDraftId: null,
|
||||
composeRemoteDraft: null,
|
||||
composeAttachments: [],
|
||||
@@ -102,7 +103,7 @@
|
||||
imageEditorBlob: null,
|
||||
draftSaveTimer: null,
|
||||
prefs: {
|
||||
theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", skinDepth: "standard", skinGradients: false, presetScaleUnlocked: false, density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
|
||||
theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", skinDepth: "standard", skinGradients: false, themeLightFrom: "07:00", themeDarkFrom: "19:00", accountColor: "", presetScaleUnlocked: false, density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
|
||||
composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true,
|
||||
colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13,
|
||||
rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4,
|
||||
|
||||
@@ -278,7 +278,7 @@ function appDialog({ title = "RustBox", message = "", input = null, selectOption
|
||||
header.append(el("strong", "", title));
|
||||
const close = el("button", "icon-button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", cancelLabel);
|
||||
close.setAttribute("aria-label", trText(cancelLabel));
|
||||
close.innerHTML = svgIcon("x");
|
||||
header.append(close);
|
||||
const body = el("div", "app-prompt-body");
|
||||
@@ -300,9 +300,9 @@ function appDialog({ title = "RustBox", message = "", input = null, selectOption
|
||||
body.append(control);
|
||||
}
|
||||
const actions = el("footer", "dialog-actions app-prompt-actions");
|
||||
const cancel = el("button", "button quiet", cancelLabel);
|
||||
const cancel = el("button", "button quiet", trText(cancelLabel));
|
||||
cancel.type = "button";
|
||||
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
|
||||
const confirm = el("button", danger ? "button danger" : "button primary", trText(confirmLabel));
|
||||
confirm.type = "submit";
|
||||
actions.append(cancel, confirm);
|
||||
form.append(header, body, actions);
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
|
||||
function clockMinutes(value, fallback) {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(String(value || ""));
|
||||
return match ? Number(match[1]) * 60 + Number(match[2]) : fallback;
|
||||
}
|
||||
|
||||
// "schedule": light between theme_light_from and theme_dark_from (local time),
|
||||
// dark otherwise; the window may wrap past midnight.
|
||||
function scheduledTheme(prefs = state.prefs, now = new Date()) {
|
||||
const lightFrom = clockMinutes(prefs.themeLightFrom, 7 * 60);
|
||||
const darkFrom = clockMinutes(prefs.themeDarkFrom, 19 * 60);
|
||||
const minute = now.getHours() * 60 + now.getMinutes();
|
||||
if (lightFrom === darkFrom) return "light";
|
||||
const light = lightFrom < darkFrom ? minute >= lightFrom && minute < darkFrom : minute >= lightFrom || minute < darkFrom;
|
||||
return light ? "light" : "dark";
|
||||
}
|
||||
|
||||
function resolveTheme(theme) {
|
||||
if (theme === "schedule") return scheduledTheme();
|
||||
if (theme !== "system") return theme;
|
||||
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
@@ -14,6 +31,7 @@ const preferenceMap = {
|
||||
dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height",
|
||||
readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style",
|
||||
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background", adaptiveEmailColors: "adaptive_email_colors", skinDepth: "skin_depth", skinGradients: "skin_gradients",
|
||||
themeLightFrom: "theme_light_from", themeDarkFrom: "theme_dark_from", accountColor: "account_color",
|
||||
};
|
||||
const preferenceTimers = new Map();
|
||||
let presetPromotionTimer = null;
|
||||
@@ -40,19 +58,19 @@ const interfaceStyleProfiles = {
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#dce9ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
gmail: {
|
||||
label: "Gmail inspired",
|
||||
label: "Gmail",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 248, messagePaneWidth: 440, borderRadius: 10, contrast: "soft", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#d3e3fd", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
proton: {
|
||||
label: "Proton inspired",
|
||||
label: "Proton",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 242, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#e7e1ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
zoho: {
|
||||
label: "Zoho inspired",
|
||||
label: "Zoho",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 420, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "compact", sidebarItemHeight: 30, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#e1ebff", unreadFontWeight: 710, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
outlook: {
|
||||
label: "Outlook inspired",
|
||||
label: "Outlook",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 232, messagePaneWidth: 420, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "compact", sidebarItemHeight: 30, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#deecf9", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
minimal: {
|
||||
@@ -64,19 +82,19 @@ const interfaceStyleProfiles = {
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 400, borderRadius: 2, contrast: "normal", iconSize: 17, toolbarDensity: "compact", sidebarItemHeight: 27, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#32373c", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
fastmail: {
|
||||
label: "Fastmail inspired",
|
||||
label: "Fastmail",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 240, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#d9e8ff", unreadFontWeight: 710, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
"apple-mail": {
|
||||
label: "Apple Mail inspired",
|
||||
label: "Apple Mail",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 54, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 9, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 920, composerWidth: 920, selectionColor: "#dcebff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
yahoo: {
|
||||
label: "Yahoo Mail inspired",
|
||||
label: "Yahoo",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 10, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#ede3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
hey: {
|
||||
label: "HEY inspired",
|
||||
label: "HEY",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "humanist", fontSize: 14, rowHeight: 62, folderPaneWidth: 250, messagePaneWidth: 450, borderRadius: 12, contrast: "normal", iconSize: 19, toolbarDensity: "spacious", sidebarItemHeight: 36, readerMaxWidth: 900, composerWidth: 930, selectionColor: "#fff0a8", unreadFontWeight: 760, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
nord: {
|
||||
@@ -88,27 +106,27 @@ const interfaceStyleProfiles = {
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
github: {
|
||||
label: "GitHub inspired",
|
||||
label: "GitHub",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#ddf4ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
gitlab: {
|
||||
label: "GitLab inspired",
|
||||
label: "GitLab",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#eee7ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
discord: {
|
||||
label: "Discord inspired",
|
||||
label: "Discord",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 246, messagePaneWidth: 440, borderRadius: 8, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 950, selectionColor: "#e0e3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
slack: {
|
||||
label: "Slack inspired",
|
||||
label: "Slack",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#f3e6f5", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
reddit: {
|
||||
label: "Reddit inspired",
|
||||
label: "Reddit",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 31, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#fff0e8", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
notion: {
|
||||
label: "Notion inspired",
|
||||
label: "Notion",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 58, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 5, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 900, composerWidth: 920, selectionColor: "#efefef", unreadFontWeight: 700, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
dracula: {
|
||||
@@ -156,17 +174,41 @@ const interfaceStyleProfiles = {
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 10, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#1e3a5f", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
linear: {
|
||||
label: "Linear inspired",
|
||||
label: "Linear",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 46, folderPaneWidth: 232, messagePaneWidth: 440, borderRadius: 6, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 28, readerMaxWidth: 860, composerWidth: 940, selectionColor: "#1d1f33", unreadFontWeight: 640, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
material: {
|
||||
label: "Material You inspired",
|
||||
label: "Material You",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 256, messagePaneWidth: 440, borderRadius: 12, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 40, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#4a4458", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
retro: {
|
||||
label: "Retro 95",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 220, messagePaneWidth: 420, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#000080", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
winxp: {
|
||||
label: "Windows XP",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 48, folderPaneWidth: 232, messagePaneWidth: 430, borderRadius: 3, contrast: "normal", iconSize: 16, toolbarDensity: "normal", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#c1d2ee", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
osx: {
|
||||
label: "Mac OS X",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 430, borderRadius: 5, contrast: "normal", iconSize: 16, toolbarDensity: "normal", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#c5d8f4", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
ios6: {
|
||||
label: "iOS 6",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 62, folderPaneWidth: 250, messagePaneWidth: 440, borderRadius: 5, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 40, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cfe0f7", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
win7: {
|
||||
label: "Windows 7",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 3, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 960, composerWidth: 900, selectionColor: "#cce8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
"frutiger-aero": {
|
||||
label: "Frutiger Aero",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 244, messagePaneWidth: 440, borderRadius: 12, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 36, readerMaxWidth: 900, composerWidth: 940, selectionColor: "#c9ecff", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
beos: {
|
||||
label: "BeOS",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 46, folderPaneWidth: 224, messagePaneWidth: 430, borderRadius: 2, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#d0dcf2", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
};
|
||||
|
||||
function resolvedInterfaceSkin(prefs = state.prefs) {
|
||||
@@ -226,10 +268,10 @@ function translateScopedUiText(locale) {
|
||||
const trailing = original.match(/\s*$/)?.[0] || "";
|
||||
node.nodeValue = `${leading}${translated}${trailing}`;
|
||||
}
|
||||
const nodes = [scope, ...scope.querySelectorAll("[title],[aria-label],[placeholder],[data-placeholder]")];
|
||||
const nodes = [scope, ...scope.querySelectorAll("[title],[aria-label],[placeholder],[data-placeholder],optgroup[label]")];
|
||||
for (const node of nodes) {
|
||||
const originals = localizedAttributes.get(node) || {};
|
||||
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) {
|
||||
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder", "label"]) {
|
||||
if (!node.hasAttribute(attribute)) continue;
|
||||
if (!(attribute in originals)) originals[attribute] = node.getAttribute(attribute);
|
||||
const translated = map[originals[attribute]];
|
||||
@@ -267,6 +309,7 @@ function applyPreferences() {
|
||||
const paletteStyle = activeInterfaceStyle(p);
|
||||
const fixedStyle = layoutInterfaceStyle(p);
|
||||
const skinDepth = resolvedSkinDepth(p);
|
||||
const previousLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`;
|
||||
const visual = fixedStyle
|
||||
? { ...p, ...fixedStyle.preferences }
|
||||
: p;
|
||||
@@ -274,6 +317,22 @@ function applyPreferences() {
|
||||
root.dataset.interfaceSkin = resolvedInterfaceSkin(p);
|
||||
root.dataset.skinDepth = skinDepth;
|
||||
root.dataset.skinGradients = String(Boolean(p.skinGradients));
|
||||
if (/^#[0-9a-f]{6}$/i.test(p.accountColor || "")) {
|
||||
root.dataset.accountColor = "true";
|
||||
root.style.setProperty("--account-color", p.accountColor);
|
||||
} else {
|
||||
delete root.dataset.accountColor;
|
||||
root.style.removeProperty("--account-color");
|
||||
}
|
||||
const nextLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`;
|
||||
if (root.dataset.lookReady === "true" && previousLook !== nextLook && !p.reduceMotion) {
|
||||
root.classList.add("theme-transition");
|
||||
clearTimeout(applyPreferences.transitionTimer);
|
||||
applyPreferences.transitionTimer = setTimeout(() => root.classList.remove("theme-transition"), 360);
|
||||
}
|
||||
root.dataset.lookReady = "true";
|
||||
const scheduleRow = $("#pref-theme-schedule-row");
|
||||
if (scheduleRow) scheduleRow.classList.toggle("hidden", p.theme !== "schedule");
|
||||
root.dataset.density = visual.density;
|
||||
root.dataset.preview = visual.preview;
|
||||
root.dataset.scale = visual.scale;
|
||||
@@ -316,7 +375,11 @@ function applyPreferences() {
|
||||
if (lockedRoundcubeSkin) {
|
||||
["--selected", "--folder-bg", "--message-bg", "--reader-bg"].forEach((name) => root.style.removeProperty(name));
|
||||
} else {
|
||||
root.style.setProperty("--selected", visual.selectionColor || "#28352e");
|
||||
// The stored default (#28352e) was picked for the dark theme; treat it as
|
||||
// "automatic" so the light theme keeps its own readable selection colour.
|
||||
const selection = String(visual.selectionColor || "").toLowerCase();
|
||||
if (!selection || selection === "#28352e") root.style.removeProperty("--selected");
|
||||
else root.style.setProperty("--selected", visual.selectionColor);
|
||||
root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)");
|
||||
root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)");
|
||||
root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)");
|
||||
@@ -339,7 +402,7 @@ function applyPreferences() {
|
||||
const displayedMessageBackground = presetColor("--message-bg", p.messageBackground || "#181c19");
|
||||
const displayedReaderBackground = presetColor("--reader-bg", p.readerBackground || "#181c19");
|
||||
const values = {
|
||||
"#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-skin-depth": skinDepth, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
|
||||
"#pref-theme": p.theme, "#pref-theme-light-from": p.themeLightFrom, "#pref-theme-dark-from": p.themeDarkFrom, "#pref-account-color": p.accountColor || "#4d75e6", "#pref-interface-skin": p.interfaceSkin, "#pref-skin-depth": skinDepth, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
|
||||
"#pref-language": p.language, "#pref-color-preset": paletteStyle ? "custom" : p.colorPreset, "#pref-accent": displayedAccent,
|
||||
"#pref-background": displayedBackground, "#pref-panel": displayedPanel, "#pref-text-color": displayedText,
|
||||
"#pref-font-family": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight,
|
||||
@@ -350,7 +413,7 @@ function applyPreferences() {
|
||||
"#pref-selection-color": visual.selectionColor, "#pref-unread-weight": visual.unreadFontWeight, "#pref-unread-badge": visual.unreadBadgeStyle, "#pref-scrollbar-density": visual.scrollbarDensity,
|
||||
};
|
||||
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
|
||||
const checks = { "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-skin-gradients": p.skinGradients, "#pref-adaptive-email-colors": p.adaptiveEmailColors, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
|
||||
const checks = { "#pref-account-color-enabled": Boolean(p.accountColor), "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-skin-gradients": p.skinGradients, "#pref-adaptive-email-colors": p.adaptiveEmailColors, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
|
||||
Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); });
|
||||
const outputs = { "#pref-accent-value": displayedAccent, "#pref-background-value": displayedBackground, "#pref-panel-value": displayedPanel, "#pref-text-color-value": displayedText, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) };
|
||||
Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; });
|
||||
@@ -382,6 +445,7 @@ function applyPreferences() {
|
||||
immersive: tr("skin_depth_immersive_note", "Everything in Standard plus the style's shapes, typography, floating panes, app bar, selection markers and backdrop."),
|
||||
})[skinDepth];
|
||||
}
|
||||
updateContrastWarning(Boolean(paletteStyle));
|
||||
const skinNote = $("#pref-interface-skin-note");
|
||||
if (skinNote) {
|
||||
const styleLabel = state.prefs.language === "pl" ? (state.translations._text?.[paletteStyle?.label] || paletteStyle?.label) : paletteStyle?.label;
|
||||
@@ -391,11 +455,66 @@ function applyPreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
let contrastProbe = null;
|
||||
|
||||
function resolvedCssColor(expression) {
|
||||
if (!contrastProbe) {
|
||||
contrastProbe = document.createElement("span");
|
||||
contrastProbe.setAttribute("aria-hidden", "true");
|
||||
contrastProbe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none";
|
||||
document.body.append(contrastProbe);
|
||||
}
|
||||
contrastProbe.style.color = "";
|
||||
contrastProbe.style.color = expression;
|
||||
const value = getComputedStyle(contrastProbe).color;
|
||||
const rgb = value.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||
if (rgb) return rgb.slice(1, 4).map((part) => Number(part) / 255);
|
||||
const srgb = value.match(/color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
|
||||
return srgb ? srgb.slice(1, 4).map(Number) : null;
|
||||
}
|
||||
|
||||
function contrastRatio(first, second) {
|
||||
const luminance = (rgb) => {
|
||||
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const [a, b] = [luminance(first), luminance(second)].sort((x, y) => y - x);
|
||||
return (a + 0.05) / (b + 0.05);
|
||||
}
|
||||
|
||||
// Warns while the user's own colours make text hard to read (WCAG AA, 4.5:1
|
||||
// for text, 3:1 for the accent against panels). Predefined palettes are
|
||||
// checked at release time, so the hint is only shown for custom colours.
|
||||
function updateContrastWarning(usingPredefinedPalette) {
|
||||
const warning = $("#pref-contrast-warning");
|
||||
if (!warning) return;
|
||||
if (usingPredefinedPalette || !document.body) { warning.classList.add("hidden"); return; }
|
||||
const color = (name) => resolvedCssColor(`var(${name})`);
|
||||
const checks = [
|
||||
["text", "panel", 4.5, "Text on panels"],
|
||||
["text", "bg", 4.5, "Text on the background"],
|
||||
["text", "selected", 4.5, "Text on the selection"],
|
||||
["muted", "panel", 3, "Secondary text"],
|
||||
["accent", "panel", 3, "Accent on panels"],
|
||||
["accent-ink", "accent", 4.5, "Button text on the accent"],
|
||||
];
|
||||
const problems = [];
|
||||
for (const [fg, bg, minimum, label] of checks) {
|
||||
const a = color(`--${fg}`);
|
||||
const b = color(`--${bg}`);
|
||||
if (!a || !b) continue;
|
||||
const ratio = contrastRatio(a, b);
|
||||
if (ratio < minimum) problems.push(`${trText(label)} ${ratio.toFixed(1)}:1`);
|
||||
}
|
||||
warning.classList.toggle("hidden", problems.length === 0);
|
||||
warning.textContent = problems.length ? `${tr("contrast_warning", "Low contrast")}: ${problems.join(" · ")}` : "";
|
||||
}
|
||||
|
||||
function normalizeServerPreferences(data) {
|
||||
if (!data) return;
|
||||
state.prefs = {
|
||||
...state.prefs,
|
||||
theme: data.theme || root.dataset.defaultTheme || "system", interfaceSkin: data.interface_skin || "default", skinDepth: skinDepths.includes(data.skin_depth) ? data.skin_depth : "standard", skinGradients: Boolean(data.skin_gradients), presetScaleUnlocked: Boolean(data.preset_scale_unlocked), density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
|
||||
theme: data.theme || root.dataset.defaultTheme || "system", interfaceSkin: data.interface_skin || "default", skinDepth: skinDepths.includes(data.skin_depth) ? data.skin_depth : "standard", skinGradients: Boolean(data.skin_gradients), themeLightFrom: data.theme_light_from || "07:00", themeDarkFrom: data.theme_dark_from || "19:00", accountColor: data.account_color || "", presetScaleUnlocked: Boolean(data.preset_scale_unlocked), density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
|
||||
composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false,
|
||||
scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage",
|
||||
accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13,
|
||||
@@ -660,7 +779,7 @@ function savePreference(key, value) {
|
||||
}
|
||||
|
||||
async function resetAppearance() {
|
||||
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", skin_depth: "standard", skin_gradients: false, preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "", adaptive_email_colors: false };
|
||||
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", skin_depth: "standard", skin_gradients: false, theme_light_from: "07:00", theme_dark_from: "19:00", account_color: "", preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "", adaptive_email_colors: false };
|
||||
try {
|
||||
normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }));
|
||||
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: true }) });
|
||||
|
||||
@@ -35,6 +35,15 @@ function updateMessageLoadControls() {
|
||||
}
|
||||
}
|
||||
|
||||
function messageListSkeleton(rows = 8) {
|
||||
const widths = [[38, 72], [46, 58], [30, 80], [52, 64], [34, 70], [44, 54], [40, 76], [28, 62]];
|
||||
const items = Array.from({ length: rows }, (_, index) => {
|
||||
const [from, subject] = widths[index % widths.length];
|
||||
return `<div class="message-skeleton-row" aria-hidden="true"><span class="skeleton-dot"></span><span class="skeleton-line" style="--w:${from}%"></span><span class="skeleton-line skeleton-date"></span><span class="skeleton-line skeleton-wide" style="--w:${subject}%"></span></div>`;
|
||||
}).join("");
|
||||
return `<div class="message-skeleton" role="status"><span class="sr-only">Loading messages…</span>${items}</div>`;
|
||||
}
|
||||
|
||||
async function loadMessages(reset = false, options = {}) {
|
||||
const list = $("#message-list");
|
||||
const mode = mailboxLoadingMode();
|
||||
@@ -49,7 +58,7 @@ async function loadMessages(reset = false, options = {}) {
|
||||
state.selectedMessages.clear();
|
||||
state.selectionAnchorKey = null;
|
||||
clearActiveSearchContext();
|
||||
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
|
||||
list.innerHTML = messageListSkeleton();
|
||||
}
|
||||
}
|
||||
$("#folder-title").textContent = state.folder;
|
||||
|
||||
@@ -677,27 +677,84 @@ async function deleteFolder() {
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
closeContextSubmenu();
|
||||
const menu = $("#context-menu");
|
||||
menu.classList.add("hidden");
|
||||
menu.replaceChildren();
|
||||
}
|
||||
|
||||
function showContextMenu(x, y, items) {
|
||||
const menu = $("#context-menu");
|
||||
menu.replaceChildren();
|
||||
function contextMenuButtons(menu, items, { submenuHost = null } = {}) {
|
||||
for (const item of items) {
|
||||
if (item === "separator") { menu.append(el("div", "separator")); continue; }
|
||||
const button = el("button", item.danger ? "danger" : "", trText(item.label));
|
||||
button.type = "button";
|
||||
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
|
||||
button.setAttribute("role", "menuitem");
|
||||
if (item.submenu) {
|
||||
button.classList.add("has-submenu");
|
||||
button.setAttribute("aria-haspopup", "menu");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.append(el("span", "context-menu-arrow", "›"));
|
||||
const open = () => openContextSubmenu(button, item.submenu);
|
||||
button.addEventListener("mouseenter", open);
|
||||
button.addEventListener("click", (event) => { event.stopPropagation(); open(); });
|
||||
button.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
open();
|
||||
$("#context-submenu button")?.focus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
|
||||
if (submenuHost) button.addEventListener("mouseenter", () => closeContextSubmenu());
|
||||
}
|
||||
menu.append(button);
|
||||
}
|
||||
menu.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function placeContextMenu(menu, x, y) {
|
||||
menu.style.maxHeight = `${Math.max(160, innerHeight - 12)}px`;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(6, Math.min(x, innerWidth - rect.width - 6))}px`;
|
||||
menu.style.top = `${Math.max(6, Math.min(y, innerHeight - rect.height - 6))}px`;
|
||||
}
|
||||
|
||||
function closeContextSubmenu() {
|
||||
$("#context-submenu")?.remove();
|
||||
$$("#context-menu .has-submenu[aria-expanded='true']").forEach((node) => node.setAttribute("aria-expanded", "false"));
|
||||
}
|
||||
|
||||
function openContextSubmenu(anchor, items) {
|
||||
if (anchor.getAttribute("aria-expanded") === "true" && $("#context-submenu")) return;
|
||||
closeContextSubmenu();
|
||||
anchor.setAttribute("aria-expanded", "true");
|
||||
const submenu = el("div", "context-menu context-submenu");
|
||||
submenu.id = "context-submenu";
|
||||
submenu.setAttribute("role", "menu");
|
||||
contextMenuButtons(submenu, items);
|
||||
submenu.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") { event.preventDefault(); closeContextSubmenu(); anchor.focus(); }
|
||||
});
|
||||
document.body.append(submenu);
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const parentRect = anchor.closest(".context-menu").getBoundingClientRect();
|
||||
submenu.style.maxHeight = `${Math.max(160, innerHeight - 12)}px`;
|
||||
const rect = submenu.getBoundingClientRect();
|
||||
const fitsRight = parentRect.right + rect.width + 2 <= innerWidth - 6;
|
||||
const left = fitsRight ? parentRect.right + 2 : Math.max(6, parentRect.left - rect.width - 2);
|
||||
submenu.style.left = `${left}px`;
|
||||
submenu.style.top = `${Math.max(6, Math.min(anchorRect.top - 4, innerHeight - rect.height - 6))}px`;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y, items) {
|
||||
const menu = $("#context-menu");
|
||||
closeContextSubmenu();
|
||||
menu.replaceChildren();
|
||||
contextMenuButtons(menu, items, { submenuHost: true });
|
||||
menu.classList.remove("hidden");
|
||||
placeContextMenu(menu, x, y);
|
||||
}
|
||||
|
||||
async function ensureMessageOpen(message) {
|
||||
const folder = message.folder || state.folder;
|
||||
if (!state.currentDetail || state.currentDetail.uid !== message.uid || state.currentDetail.folder !== folder) {
|
||||
@@ -715,40 +772,56 @@ function openMessageContextMenu(x, y, message) {
|
||||
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || "");
|
||||
const seen = flags.includes("\\Seen");
|
||||
const flagged = flags.includes("\\Flagged");
|
||||
const folder = message.folder || state.folder;
|
||||
const sieveEnabled = state.authPolicy.sieveEnabled !== false;
|
||||
showContextMenu(x, y, [
|
||||
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
|
||||
{ label: "Open", action: () => openMessage(folder, message.uid) },
|
||||
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
|
||||
"separator",
|
||||
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
|
||||
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
|
||||
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
|
||||
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
|
||||
{ label: "Forward", submenu: [
|
||||
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
|
||||
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
|
||||
] },
|
||||
"separator",
|
||||
{ label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } },
|
||||
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
|
||||
{ label: "Labels", submenu: [
|
||||
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
|
||||
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
|
||||
] },
|
||||
{ label: "Snooze", submenu: [
|
||||
{ label: "Snooze\u2026", action: async () => { await ensureMessageOpen(message); await promptSnoozeCurrent(); } },
|
||||
{ label: "View snoozed messages\u2026", action: async () => { await showSnoozedMessages(); } },
|
||||
] },
|
||||
"separator",
|
||||
{ label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } },
|
||||
{ label: "Snooze\u2026", action: async () => { await ensureMessageOpen(message); await promptSnoozeCurrent(); } },
|
||||
{ label: "View snoozed messages\u2026", action: async () => { await showSnoozedMessages(); } },
|
||||
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } },
|
||||
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } },
|
||||
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
|
||||
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
|
||||
{ label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } },
|
||||
...(state.authPolicy.sieveEnabled === false ? [] : [
|
||||
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", folder); if (target) await moveCurrentTo(target); } },
|
||||
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", folder); if (target) await copyCurrentTo(target); } },
|
||||
{ label: isJunkFolder(folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } },
|
||||
"separator",
|
||||
...(sieveEnabled ? [{ label: "Filters", submenu: [
|
||||
{ label: "Always move messages from this sender…", action: () => alwaysMoveFromSender(message) },
|
||||
"separator",
|
||||
{ label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } },
|
||||
{ label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } },
|
||||
{ label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } },
|
||||
]),
|
||||
{ label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
|
||||
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
|
||||
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
|
||||
] }] : []),
|
||||
{ label: "Sender", submenu: [
|
||||
{ label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
|
||||
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
|
||||
{ label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } },
|
||||
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
|
||||
] },
|
||||
{ label: "More", submenu: [
|
||||
{ label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } },
|
||||
{ label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } },
|
||||
{ label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } },
|
||||
{ label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } },
|
||||
{ label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } },
|
||||
] },
|
||||
"separator",
|
||||
{ label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } },
|
||||
{ label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } },
|
||||
{ label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } },
|
||||
{ label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } },
|
||||
{ label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } },
|
||||
{ label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } },
|
||||
{ label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } },
|
||||
]);
|
||||
}
|
||||
@@ -1251,6 +1324,7 @@ async function prepareQuickFilterAdvanced(config) {
|
||||
$("#sieve-builder-condition-2").value = "";
|
||||
$("#sieve-builder-condition-3").value = "";
|
||||
$("#sieve-builder-extra-conditions").replaceChildren();
|
||||
$("#sieve-builder-exceptions")?.replaceChildren();
|
||||
$("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : "";
|
||||
$("#sieve-builder-extra-actions").replaceChildren();
|
||||
updateSieveBuilder();
|
||||
@@ -1329,6 +1403,8 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true;
|
||||
const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags");
|
||||
const activate = document.createElement("input"); activate.type = "checkbox"; activate.checked = !activeScript;
|
||||
const applyExisting = document.createElement("input"); applyExisting.type = "checkbox";
|
||||
const sourceFolder = detail.folder || state.folder;
|
||||
|
||||
const targetField = makeField("Destination folder", target);
|
||||
grid.append(
|
||||
@@ -1342,7 +1418,8 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read"));
|
||||
if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags";
|
||||
const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving"));
|
||||
toggles.append(stopLabel, readLabel, activateLabel);
|
||||
const applyLabel = el("label", "checkbox-row"); applyLabel.append(applyExisting, el("span", "", fmtText("Also apply to messages already in “{folder}”", { folder: sourceFolder })));
|
||||
toggles.append(stopLabel, readLabel, activateLabel, applyLabel);
|
||||
body.append(toggles);
|
||||
const preview = el("div", "quick-filter-preview"); body.append(preview);
|
||||
const hint = el("div", "muted quick-filter-hint"); body.append(hint);
|
||||
@@ -1370,7 +1447,7 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
preview.textContent = `If ${conditionLabel} ${match.value === "contains" ? "contains" : "is"} “${displayValue}” → ${actionLabel}${needsFolder && target.value ? ` “${target.value}”` : ""}${markRead.checked && action.value !== "mark-read" ? " + mark as read" : ""}.`;
|
||||
hint.textContent = missing.length
|
||||
? `Server does not advertise: ${missing.join(", ")}`
|
||||
: `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. It applies to new incoming mail; existing messages are not changed.`;
|
||||
: `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. ${applyExisting.checked ? `Matching messages already in “${sourceFolder}” are processed after you confirm.` : "It applies to new incoming mail; existing messages are not changed."}`;
|
||||
save.disabled = missing.length > 0;
|
||||
};
|
||||
condition.addEventListener("change", () => { const next = sourceValues[condition.value]; value.value = next.value; match.value = next.match; update(); });
|
||||
@@ -1381,6 +1458,7 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
script.addEventListener("change", update);
|
||||
activate.addEventListener("change", update);
|
||||
markRead.addEventListener("change", update);
|
||||
applyExisting.addEventListener("change", update);
|
||||
|
||||
let settled = false;
|
||||
const cleanup = () => { if (settled) return; settled = true; if (dialog.open) dialog.close(); else dialog.remove(); };
|
||||
@@ -1399,17 +1477,11 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
setBusy(save, true);
|
||||
try {
|
||||
const config = { kind: condition.value, match: match.value, value: value.value.trim(), action: action.value, target: target.value, stop: stop.checked, markRead: markRead.checked };
|
||||
const built = buildQuickFilterRule(config);
|
||||
const existing = state.sieveScripts.find((item) => item.name === script.value);
|
||||
let current = "";
|
||||
if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method: "GET" })).content || "";
|
||||
const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
|
||||
const validation = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content }) });
|
||||
if (!validation.valid) throw new Error(validation.error || "Filter validation failed");
|
||||
await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method: "PUT", body: JSON.stringify({ content }) });
|
||||
if (activate.checked || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(script.value)}/activate`, { method: "POST", body: "{}" });
|
||||
if (["move", "copy"].includes(config.action) && !await ensureSieveTargetFolders(quickFilterActions(config))) return;
|
||||
await appendRuleToSieveScript(buildQuickFilterRule(config), script.value, activate.checked);
|
||||
cleanup();
|
||||
toast(`Filter added to ${script.value}`, "success");
|
||||
toast(fmtText("Filter added to {script}", { script: script.value }), "success");
|
||||
if (applyExisting.checked) await applySieveRuleToFolder({ folder: sourceFolder, tree: quickFilterTree(config), actions: quickFilterActions(config) });
|
||||
} catch (error) {
|
||||
hint.textContent = error.message;
|
||||
toast(error.message, "error");
|
||||
|
||||
@@ -204,7 +204,53 @@ function updateCalendarRecurrenceUi() {
|
||||
$("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom");
|
||||
}
|
||||
|
||||
const calendarReminderChoices = [
|
||||
["", "None"], ["0", "At start"], ["5", "5 minutes"], ["10", "10 minutes"], ["15", "15 minutes"], ["30", "30 minutes"],
|
||||
["60", "1 hour"], ["120", "2 hours"], ["1440", "1 day before"], ["2880", "2 days before"], ["10080", "1 week before"],
|
||||
];
|
||||
|
||||
function fillCalendarReminderSelects() {
|
||||
for (const selector of ["#calendar-reminder", "#calendar-reminder-extra", "#calendar-default-reminder", "#calendar-default-reminder-extra"]) {
|
||||
const select = $(selector);
|
||||
if (!select) continue;
|
||||
const current = select.value;
|
||||
select.replaceChildren(...calendarReminderChoices.map(([value, label]) => new Option(trText(label), value)));
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
function setReminderSelect(selector, minutes) {
|
||||
const select = $(selector);
|
||||
if (!select) return;
|
||||
const value = minutes == null ? "" : String(minutes);
|
||||
select.value = Array.from(select.options).some((option) => option.value === value) ? value : "";
|
||||
}
|
||||
|
||||
function reminderSelectValue(selector) {
|
||||
const value = $(selector)?.value ?? "";
|
||||
return value === "" ? null : Number(value);
|
||||
}
|
||||
|
||||
async function loadCalendarReminderDefaults() {
|
||||
try {
|
||||
state.calendarSettings = await request("/calendar/settings", { method: "GET" });
|
||||
} catch (_) {
|
||||
state.calendarSettings = state.calendarSettings || null;
|
||||
}
|
||||
fillCalendarReminderSelects();
|
||||
setReminderSelect("#calendar-default-reminder", state.calendarSettings?.default_reminder_minutes);
|
||||
setReminderSelect("#calendar-default-reminder-extra", state.calendarSettings?.default_extra_reminder_minutes);
|
||||
}
|
||||
|
||||
async function saveCalendarReminderDefault(field, selector) {
|
||||
try {
|
||||
state.calendarSettings = await request("/calendar/settings", { method: "PUT", body: JSON.stringify({ [field]: reminderSelectValue(selector) }) });
|
||||
toast(trText("Calendar reminder defaults saved"));
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function editCalendarEvent(event = null) {
|
||||
fillCalendarReminderSelects();
|
||||
$("#calendar-form").classList.remove("hidden");
|
||||
$("#calendar-event-id").value = event?.id || "";
|
||||
$("#calendar-title").value = event?.title || "";
|
||||
@@ -226,8 +272,9 @@ function editCalendarEvent(event = null) {
|
||||
$("#calendar-description").value = event?.description || "";
|
||||
const color = String(event?.color || "");
|
||||
$("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280";
|
||||
const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes);
|
||||
$("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : "";
|
||||
// New events start with the reminders chosen in Settings -> Notifications.
|
||||
setReminderSelect("#calendar-reminder", event ? event.reminder_minutes : state.calendarSettings?.default_reminder_minutes);
|
||||
setReminderSelect("#calendar-reminder-extra", event ? event.reminder_extra_minutes : state.calendarSettings?.default_extra_reminder_minutes);
|
||||
$("#calendar-delete").disabled = !event;
|
||||
}
|
||||
|
||||
@@ -253,7 +300,8 @@ async function saveCalendarEvent(event) {
|
||||
status: $("#calendar-status").value || "confirmed",
|
||||
meeting_url: $("#calendar-meeting-url").value.trim() || null,
|
||||
color: $("#calendar-color").value || null,
|
||||
reminder_minutes: $("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value),
|
||||
reminder_minutes: reminderSelectValue("#calendar-reminder"),
|
||||
reminder_extra_minutes: reminderSelectValue("#calendar-reminder-extra"),
|
||||
};
|
||||
const payload = id ? base : { ...base, source: "manual", source_uid: null, source_message_uid: null, source_message_folder: null };
|
||||
try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); }
|
||||
|
||||
@@ -97,6 +97,7 @@ async function loadCombinedNotificationPreferences() {
|
||||
}
|
||||
|
||||
async function loadNotificationPreferences() {
|
||||
void loadCalendarReminderDefaults();
|
||||
const prefs = await loadNotificationPreferencesForAccount(state.accountId, { silent: false });
|
||||
if (!prefs) return;
|
||||
$("#notify-enabled").checked = Boolean(prefs.enabled);
|
||||
@@ -404,8 +405,24 @@ function notifyNewMail(data, accountId) {
|
||||
if (prefs.sound) playNotificationSound();
|
||||
}
|
||||
|
||||
function calendarReminderStorageKey(event) {
|
||||
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}`;
|
||||
function calendarReminderStorageKey(event, minutes) {
|
||||
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}.${minutes}`;
|
||||
}
|
||||
|
||||
// Timed events: start minus the reminder. All-day events: the reminder counts
|
||||
// from the start of the working day (calendar settings, default 09:00) of the
|
||||
// event's date, so "1 day before" fires the previous morning, not at midnight.
|
||||
function calendarReminderTrigger(event, minutes) {
|
||||
const offset = Math.max(0, Number(minutes) || 0) * 60000;
|
||||
if (event.all_day) {
|
||||
const day = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(event.start_time || ""));
|
||||
if (!day) return NaN;
|
||||
const [hours, mins] = String(state.calendarSettings?.working_hours_start || "09:00").split(":").map(Number);
|
||||
const base = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]), Number.isFinite(hours) ? hours : 9, Number.isFinite(mins) ? mins : 0);
|
||||
return base.getTime() - offset;
|
||||
}
|
||||
const startMs = new Date(event.start_time).getTime();
|
||||
return Number.isFinite(startMs) ? startMs - offset : NaN;
|
||||
}
|
||||
|
||||
function fireCalendarReminder(event) {
|
||||
@@ -424,19 +441,21 @@ async function checkCalendarReminders() {
|
||||
if (!state.accountId) return;
|
||||
const now = Date.now();
|
||||
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
|
||||
const end = new Date(now + 2 * 24 * 60 * 60 * 1000);
|
||||
// Look far enough ahead for the longest reminder choice (1 week before).
|
||||
const end = new Date(now + 8 * 24 * 60 * 60 * 1000);
|
||||
try {
|
||||
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
|
||||
for (const event of data.events || []) {
|
||||
if (event.reminder_minutes == null || String(event.status || "").toLowerCase() === "cancelled") continue;
|
||||
const startMs = new Date(event.start_time).getTime();
|
||||
if (!Number.isFinite(startMs)) continue;
|
||||
const trigger = startMs - Math.max(0, Number(event.reminder_minutes) || 0) * 60000;
|
||||
if (trigger > now || now - trigger > 10 * 60 * 1000) continue;
|
||||
const key = calendarReminderStorageKey(event);
|
||||
if (localStorage.getItem(key)) continue;
|
||||
localStorage.setItem(key, String(now));
|
||||
fireCalendarReminder(event);
|
||||
if (String(event.status || "").toLowerCase() === "cancelled") continue;
|
||||
for (const minutes of [event.reminder_minutes, event.reminder_extra_minutes]) {
|
||||
if (minutes == null) continue;
|
||||
const trigger = calendarReminderTrigger(event, minutes);
|
||||
if (!Number.isFinite(trigger) || trigger > now || now - trigger > 10 * 60 * 1000) continue;
|
||||
const key = calendarReminderStorageKey(event, minutes);
|
||||
if (localStorage.getItem(key)) continue;
|
||||
localStorage.setItem(key, String(now));
|
||||
fireCalendarReminder(event);
|
||||
}
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
+339
-141
@@ -31,12 +31,110 @@ function sieveQuote(value) {
|
||||
|
||||
function sieveBuilderRequirements(condition, action) {
|
||||
const requirements = new Set();
|
||||
if (["move", "copy"].includes(action)) requirements.add("fileinto");
|
||||
if (["move", "copy", "trash"].includes(action)) requirements.add("fileinto");
|
||||
if (["copy", "redirect-copy"].includes(action)) requirements.add("copy");
|
||||
if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags");
|
||||
if (["mark-read", "add-flag", "set-flags", "label"].includes(action)) requirements.add("imap4flags");
|
||||
if (condition === "has-attachment") requirements.add("mime");
|
||||
return [...requirements];
|
||||
}
|
||||
|
||||
// One catalogue for every condition / action select in the filter editor.
|
||||
const sieveConditionKinds = [
|
||||
["from", "From address"], ["from-domain", "From domain"], ["to", "To / Cc address"], ["to-only", "To address"],
|
||||
["cc-only", "Cc address"], ["subject", "Subject"], ["list-id", "List-ID"], ["has-attachment", "Has attachment"],
|
||||
["size-over", "Size larger than"], ["size-under", "Size smaller than"], ["priority-high", "High priority"],
|
||||
["header-from", "From header"], ["header-to", "To header"], ["header", "Custom header"],
|
||||
];
|
||||
const valuelessSieveKinds = new Set(["has-attachment", "priority-high"]);
|
||||
const sizeSieveKinds = new Set(["size-over", "size-under"]);
|
||||
const sieveActionKinds = [
|
||||
["move", "Move to folder"], ["trash", "Move to Trash"], ["copy", "Copy to folder"], ["label", "Add label"],
|
||||
["mark-read", "Mark read"], ["add-flag", "Add IMAP flag"], ["set-flags", "Set IMAP flags"], ["redirect", "Redirect"],
|
||||
["redirect-copy", "Redirect and keep copy"], ["discard", "Discard"], ["keep", "Keep"],
|
||||
];
|
||||
|
||||
function sieveOptionsHtml(kinds, includeNone = false) {
|
||||
const none = includeNone ? `<option value="">${escapeHtml(trText("None"))}</option>` : "";
|
||||
return none + kinds.map(([value, label]) => `<option value="${value}">${escapeHtml(trText(label))}</option>`).join("");
|
||||
}
|
||||
|
||||
function fillSieveKindSelects() {
|
||||
const fill = (select, kinds, includeNone) => {
|
||||
if (!select) return;
|
||||
const current = select.value;
|
||||
select.innerHTML = sieveOptionsHtml(kinds, includeNone);
|
||||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||||
};
|
||||
fill($("#sieve-builder-condition"), sieveConditionKinds, false);
|
||||
fill($("#sieve-builder-condition-2"), sieveConditionKinds, true);
|
||||
fill($("#sieve-builder-condition-3"), sieveConditionKinds, true);
|
||||
fill($("#sieve-builder-action"), sieveActionKinds, false);
|
||||
fill($("#sieve-builder-action-2"), sieveActionKinds, true);
|
||||
}
|
||||
|
||||
function normalizeSieveSize(value) {
|
||||
const match = String(value || "").trim().toUpperCase().match(/^(\d+)\s*([KMG])?B?$/);
|
||||
return match ? `${match[1]}${match[2] || ""}` : null;
|
||||
}
|
||||
|
||||
function sieveLabelKeyword(tagId) {
|
||||
return `RustBox-${String(tagId || "").replaceAll("-", "")}`;
|
||||
}
|
||||
|
||||
function sieveTagByKeyword(keyword) {
|
||||
return (state.tags || []).find((tag) => sieveLabelKeyword(tag.id).toLowerCase() === String(keyword || "").toLowerCase()) || null;
|
||||
}
|
||||
|
||||
function sieveConditionSummary({ condition, match, value, header, negate }) {
|
||||
const label = trText(sieveConditionLabel(condition, header));
|
||||
let text;
|
||||
if (valuelessSieveKinds.has(condition)) text = label;
|
||||
else if (sizeSieveKinds.has(condition)) text = `${label} ${value}`;
|
||||
else text = `${label} ${trText(sieveMatchLabel(match))} “${value}”`;
|
||||
return negate ? `${trText("NOT")} ${text}` : text;
|
||||
}
|
||||
|
||||
// Shared by the first/second/third conditions, the dynamic rows and the
|
||||
// exceptions: one Sieve test plus the JSON tree used by "Apply now".
|
||||
function sieveTestFromParts({ condition, match = "contains", value = "", header = "", negate = false }, label = "Condition") {
|
||||
let text = String(value || "").trim();
|
||||
if (!condition) throw new Error(`${trText(label)}: ${trText("choose a condition")}`);
|
||||
if (!valuelessSieveKinds.has(condition) && !text) throw new Error(`${trText(label)}: ${trText("a value is required")}`);
|
||||
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
|
||||
const quoted = `"${sieveQuote(text)}"`;
|
||||
let test;
|
||||
let customHeader = "";
|
||||
if (condition === "from") test = `address ${matchToken} "from" ${quoted}`;
|
||||
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" ${quoted}`;
|
||||
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] ${quoted}`;
|
||||
else if (condition === "to-only") test = `address ${matchToken} "to" ${quoted}`;
|
||||
else if (condition === "cc-only") test = `address ${matchToken} "cc" ${quoted}`;
|
||||
else if (condition === "header-from") test = `header ${matchToken} "from" ${quoted}`;
|
||||
else if (condition === "header-to") test = `header ${matchToken} "to" ${quoted}`;
|
||||
else if (condition === "subject") test = `header ${matchToken} "subject" ${quoted}`;
|
||||
else if (condition === "list-id") test = `header ${matchToken} "list-id" ${quoted}`;
|
||||
else if (sizeSieveKinds.has(condition)) {
|
||||
const size = normalizeSieveSize(text);
|
||||
if (!size) throw new Error(trText("Size must look like 500K, 2M or 1G"));
|
||||
text = size;
|
||||
test = `size :${condition === "size-over" ? "over" : "under"} ${size}`;
|
||||
} else if (condition === "has-attachment") test = 'header :mime :anychild :contains "Content-Disposition" "attachment"';
|
||||
else if (condition === "priority-high") test = 'header :matches ["x-priority", "importance"] ["1*", "2*", "high"]';
|
||||
else {
|
||||
customHeader = String(header || "").trim();
|
||||
if (!/^[A-Za-z0-9-]+$/.test(customHeader)) throw new Error(`${trText(label)}: ${trText("custom header name is invalid")}`);
|
||||
test = `header ${matchToken} "${sieveQuote(customHeader)}" ${quoted}`;
|
||||
}
|
||||
if (negate) test = `not ${test}`;
|
||||
const leaf = { test: { kind: condition, match, value: text, header: customHeader } };
|
||||
return {
|
||||
test, condition, match, value: text, header: customHeader, negate,
|
||||
summary: sieveConditionSummary({ condition, match, value: text, header: customHeader, negate }),
|
||||
requirements: sieveBuilderRequirements(condition, ""),
|
||||
tree: negate ? { not: leaf } : leaf,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSieveRequirements(script, requirements) {
|
||||
const found = new Set(requirements);
|
||||
const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => {
|
||||
@@ -56,87 +154,86 @@ function sieveMatchLabel(match) {
|
||||
}
|
||||
|
||||
function sieveConditionLabel(condition, header = "") {
|
||||
if (condition === "from") return "From address";
|
||||
if (condition === "from-domain") return "From domain";
|
||||
if (condition === "to") return "To / Cc address";
|
||||
if (condition === "header-from") return "From header";
|
||||
if (condition === "header-to") return "To header";
|
||||
if (condition === "subject") return "Subject";
|
||||
if (condition === "list-id") return "List-ID";
|
||||
return header || "Header";
|
||||
if (condition === "header") return header || "Header";
|
||||
return sieveConditionKinds.find(([value]) => value === condition)?.[1] || header || "Header";
|
||||
}
|
||||
|
||||
function buildSieveCondition(suffix = "", required = true) {
|
||||
const condition = $(`#sieve-builder-condition${suffix}`).value;
|
||||
const value = $(`#sieve-builder-value${suffix}`).value.trim();
|
||||
if (!condition || !value) {
|
||||
if (required) throw new Error("Rule value is required");
|
||||
if (!condition || (!value && !valuelessSieveKinds.has(condition))) {
|
||||
if (required) throw new Error(trText("Rule value is required"));
|
||||
return null;
|
||||
}
|
||||
const match = $(`#sieve-builder-match${suffix}`).value;
|
||||
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
|
||||
let test;
|
||||
let header = "";
|
||||
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
|
||||
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
|
||||
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
|
||||
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
|
||||
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
|
||||
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
|
||||
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
|
||||
else {
|
||||
header = $(`#sieve-builder-header${suffix}`).value.trim();
|
||||
if (!/^[A-Za-z0-9-]+$/.test(header)) {
|
||||
const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom";
|
||||
throw new Error(`${label} header name is invalid`);
|
||||
}
|
||||
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
|
||||
}
|
||||
const negate = $(`#sieve-builder-negate${suffix}`).checked;
|
||||
if (negate) test = `not ${test}`;
|
||||
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`;
|
||||
return { test, condition, match, value, header, negate, summary };
|
||||
const label = suffix === "-3" ? "Third condition" : suffix === "-2" ? "Second condition" : "Condition";
|
||||
return sieveTestFromParts({
|
||||
condition,
|
||||
match: $(`#sieve-builder-match${suffix}`).value,
|
||||
value,
|
||||
header: $(`#sieve-builder-header${suffix}`)?.value || "",
|
||||
negate: $(`#sieve-builder-negate${suffix}`).checked,
|
||||
}, label);
|
||||
}
|
||||
|
||||
function sieveFlagValues(target) {
|
||||
return String(target || "").split(",").map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function sieveActionSummary(action, target = "") {
|
||||
if (action === "move") return `${trText("Move to")} “${target}”`;
|
||||
if (action === "trash") return trText("Move to Trash");
|
||||
if (action === "copy") return `${trText("Copy to")} “${target}”`;
|
||||
if (action === "redirect") return `${trText("Redirect to")} ${target}`;
|
||||
if (action === "redirect-copy") return `${trText("Redirect a copy to")} ${target}`;
|
||||
if (action === "discard") return trText("Discard the message");
|
||||
if (action === "mark-read") return trText("Mark as read");
|
||||
if (action === "label") return `${trText("Add label")} “${target}”`;
|
||||
if (action === "add-flag") return `${trText("Add IMAP flag")} ${target}`;
|
||||
if (action === "set-flags") return `${trText("Set IMAP flags")} ${target}`;
|
||||
if (action === "stop") return trText("Stop processing more rules");
|
||||
return trText("Keep in the inbox");
|
||||
}
|
||||
|
||||
function buildSieveActionFromValues(action, target, label = "Action") {
|
||||
let command;
|
||||
let summary;
|
||||
let resolvedTarget = target;
|
||||
let tagId = "";
|
||||
const missing = (what) => new Error(`${trText(label)}: ${trText(what)}`);
|
||||
if (action === "move") {
|
||||
if (!target) throw new Error(`${label} target folder is required`);
|
||||
if (!target) throw missing("target folder is required");
|
||||
command = `fileinto "${sieveQuote(target)}";`;
|
||||
summary = `Move to “${target}”`;
|
||||
} else if (action === "trash") {
|
||||
resolvedTarget = detectSpecialFolder("trash") || "Trash";
|
||||
command = `fileinto "${sieveQuote(resolvedTarget)}";`;
|
||||
} else if (action === "copy") {
|
||||
if (!target) throw new Error(`${label} target folder is required`);
|
||||
if (!target) throw missing("target folder is required");
|
||||
command = `fileinto :copy "${sieveQuote(target)}";`;
|
||||
summary = `Copy to “${target}”`;
|
||||
} else if (action === "redirect" || action === "redirect-copy") {
|
||||
if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`);
|
||||
if (!target || !target.includes("@")) throw missing("redirect address is required");
|
||||
command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`;
|
||||
summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`;
|
||||
} else if (action === "discard") {
|
||||
command = "discard;";
|
||||
summary = "Discard the message";
|
||||
} else if (action === "mark-read") {
|
||||
command = 'addflag "\\Seen";';
|
||||
summary = "Mark as read";
|
||||
} else if (action === "label") {
|
||||
const wanted = String(target || "").trim().toLowerCase();
|
||||
const tag = (state.tags || []).find((item) => item.name.toLowerCase() === wanted || item.id === target);
|
||||
if (!tag) throw missing("choose an existing label");
|
||||
resolvedTarget = tag.name;
|
||||
tagId = tag.id;
|
||||
command = `addflag "${sieveQuote(sieveLabelKeyword(tag.id))}";`;
|
||||
} else if (action === "add-flag") {
|
||||
if (!target) throw new Error(`${label} IMAP flag is required`);
|
||||
if (!target) throw missing("IMAP flag is required");
|
||||
command = `addflag "${sieveQuote(target)}";`;
|
||||
summary = `Add IMAP flag ${target}`;
|
||||
} else if (action === "set-flags") {
|
||||
const flags = sieveFlagValues(target);
|
||||
if (!flags.length) throw new Error(`${label} IMAP flags are required`);
|
||||
if (!flags.length) throw missing("IMAP flags are required");
|
||||
resolvedTarget = flags.join(", ");
|
||||
command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`;
|
||||
summary = `Set IMAP flags ${flags.join(", ")}`;
|
||||
} else {
|
||||
command = "keep;";
|
||||
summary = "Keep in the inbox";
|
||||
}
|
||||
return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) };
|
||||
return { action, target: resolvedTarget, tagId, command, summary: sieveActionSummary(action, resolvedTarget), requirements: sieveBuilderRequirements("", action) };
|
||||
}
|
||||
|
||||
function buildSieveAction(suffix = "", required = true) {
|
||||
@@ -254,6 +351,15 @@ function parseSieveConditionToken(value) {
|
||||
let raw = String(value || "").trim();
|
||||
const negate = /^not\s+/i.test(raw);
|
||||
if (negate) raw = raw.replace(/^not\s+/i, "").trim();
|
||||
const special = (kind, extra = {}) => {
|
||||
const parsed = { raw: value, supported: true, kind, header: "", match: "contains", value: "", negate, ...extra };
|
||||
parsed.summary = sieveConditionSummary({ condition: kind, match: parsed.match, value: parsed.value, header: "", negate });
|
||||
return parsed;
|
||||
};
|
||||
const size = raw.match(/^size\s+:(over|under)\s+(\d+[KMG]?)$/i);
|
||||
if (size) return special(size[1].toLowerCase() === "over" ? "size-over" : "size-under", { value: size[2].toUpperCase() });
|
||||
if (/^header\s+:mime\s+:anychild\s+:contains\s+"content-disposition"\s+"attachment"$/i.test(raw)) return special("has-attachment");
|
||||
if (/^header\s+:matches\s+\[\s*"x-priority"\s*,\s*"importance"\s*\]\s+\[\s*"1\*"\s*,\s*"2\*"\s*,\s*"high"\s*\]$/i.test(raw)) return special("priority-high");
|
||||
const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i);
|
||||
if (!match) return { raw: value, supported: false, summary: String(value || "").trim() || "Unsupported condition" };
|
||||
const values = sieveQuotedValues(match[4]);
|
||||
@@ -265,14 +371,19 @@ function parseSieveConditionToken(value) {
|
||||
let header = primaryField;
|
||||
const testType = match[1].toLowerCase();
|
||||
if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from";
|
||||
else if (testType === "address" && fields.length === 1 && primaryField === "to" && !match[2]) kind = "to-only";
|
||||
else if (testType === "address" && fields.length === 1 && primaryField === "cc" && !match[2]) kind = "cc-only";
|
||||
else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to";
|
||||
else if (testType === "header" && primaryField === "from") kind = "header-from";
|
||||
else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to";
|
||||
else if (primaryField === "subject") kind = "subject";
|
||||
else if (primaryField === "list-id") kind = "list-id";
|
||||
const comparator = match[3].toLowerCase();
|
||||
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`;
|
||||
return { raw: value, supported: true, kind, header, match: comparator, value: targetValue, negate, summary };
|
||||
const summary = sieveConditionSummary({ condition: kind, match: comparator, value: targetValue, header, negate });
|
||||
// Only shapes RustBox itself writes can be re-evaluated by "Apply now".
|
||||
const valueList = match[4].match(/\[([^\]]*)\]\s*$/);
|
||||
const treeSafe = !(valueList && sieveQuotedValues(valueList[1]).length > 1) && (!match[2] || kind === "from-domain") && (kind !== "header" || fields.length === 1);
|
||||
return { raw: value, supported: true, kind, header, match: comparator, value: targetValue, negate, summary, treeSafe };
|
||||
}
|
||||
|
||||
function parseSieveConditionExpression(value) {
|
||||
@@ -280,7 +391,7 @@ function parseSieveConditionExpression(value) {
|
||||
const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i);
|
||||
if (!group) {
|
||||
const condition = parseSieveConditionToken(raw);
|
||||
return { logic: "allof", conditions: [condition], supported: condition.supported, mixed: false, summary: condition.summary };
|
||||
return { logic: "allof", conditions: [condition], supported: condition.supported, mixed: false, summary: condition.summary, tree: sieveConditionTree(condition) };
|
||||
}
|
||||
const logic = group[1].toLowerCase();
|
||||
const parts = splitSieveTopLevel(group[2]);
|
||||
@@ -288,48 +399,66 @@ function parseSieveConditionExpression(value) {
|
||||
let supported = true;
|
||||
let mixed = false;
|
||||
const summaries = [];
|
||||
const trees = [];
|
||||
for (const part of parts) {
|
||||
const parsed = parseSieveConditionExpression(part);
|
||||
trees.push(parsed.tree);
|
||||
if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true;
|
||||
conditions.push(...parsed.conditions);
|
||||
supported = supported && parsed.supported;
|
||||
mixed = mixed || parsed.mixed;
|
||||
summaries.push(parsed.summary);
|
||||
}
|
||||
return { logic, conditions, supported, mixed, summary: summaries.join(logic === "anyof" ? " OR " : " AND ") };
|
||||
const tree = trees.every(Boolean) ? { op: logic, args: trees } : null;
|
||||
return { logic, conditions, supported, mixed, summary: summaries.join(` ${trText(logic === "anyof" ? "OR" : "AND")} `), tree };
|
||||
}
|
||||
|
||||
// JSON form of one parsed Sieve test, as understood by POST /filters/match.
|
||||
function sieveConditionTree(condition) {
|
||||
if (!condition?.supported || !condition.kind || condition.treeSafe === false) return null;
|
||||
const leaf = { test: { kind: condition.kind, match: condition.match || "contains", value: condition.value || "", header: condition.kind === "header" ? condition.header || "" : "" } };
|
||||
return condition.negate ? { not: leaf } : leaf;
|
||||
}
|
||||
|
||||
function parseSieveActionStatement(statement) {
|
||||
const raw = String(statement || "").trim().replace(/;\s*$/, "");
|
||||
const parsed = (action, target = "", extra = {}) => ({ action, target, supported: true, summary: sieveActionSummary(action, target), ...extra });
|
||||
let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
|
||||
if (match) {
|
||||
const target = sieveUnescape(match[2]);
|
||||
const action = match[1] ? "copy" : "move";
|
||||
return { action, target, supported: true, summary: action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` };
|
||||
if (!match[1] && target === (detectSpecialFolder("trash") || "Trash")) return parsed("trash", target);
|
||||
return parsed(match[1] ? "copy" : "move", target);
|
||||
}
|
||||
match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
|
||||
if (match) {
|
||||
const target = sieveUnescape(match[2]);
|
||||
const action = match[1] ? "redirect-copy" : "redirect";
|
||||
return { action, target, supported: true, summary: action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` };
|
||||
}
|
||||
if (match) return parsed(match[1] ? "redirect-copy" : "redirect", sieveUnescape(match[2]));
|
||||
match = raw.match(/^addflag\s+([\s\S]+)$/i);
|
||||
if (match) {
|
||||
const flags = sieveQuotedValues(match[1]);
|
||||
const target = flags.join(", ");
|
||||
const markRead = flags.length === 1 && flags[0] === "\\Seen";
|
||||
return { action: markRead ? "mark-read" : "add-flag", target: markRead ? "" : target, supported: Boolean(flags.length), summary: markRead ? "Mark as read" : `Add IMAP flag ${target}` };
|
||||
if (flags.length === 1 && flags[0] === "\\Seen") return parsed("mark-read");
|
||||
const tag = flags.length === 1 ? sieveTagByKeyword(flags[0]) : null;
|
||||
if (tag) return parsed("label", tag.name, { tagId: tag.id });
|
||||
return { ...parsed("add-flag", flags.join(", ")), supported: Boolean(flags.length) };
|
||||
}
|
||||
match = raw.match(/^setflag\s+([\s\S]+)$/i);
|
||||
if (match) {
|
||||
const flags = sieveQuotedValues(match[1]);
|
||||
const target = flags.join(", ");
|
||||
return { action: "set-flags", target, supported: Boolean(flags.length), summary: `Set IMAP flags ${target}` };
|
||||
return { ...parsed("set-flags", flags.join(", ")), supported: Boolean(flags.length) };
|
||||
}
|
||||
if (/^discard$/i.test(raw)) return { action: "discard", target: "", supported: true, summary: "Discard the message" };
|
||||
if (/^keep$/i.test(raw)) return { action: "keep", target: "", supported: true, summary: "Keep in the inbox" };
|
||||
if (/^stop$/i.test(raw)) return { action: "stop", target: "", supported: true, stop: true, summary: "Stop processing more rules" };
|
||||
return { action: "unsupported", target: "", supported: false, summary: raw || "Unsupported action", raw };
|
||||
if (/^discard$/i.test(raw)) return parsed("discard");
|
||||
if (/^keep$/i.test(raw)) return parsed("keep");
|
||||
if (/^stop$/i.test(raw)) return parsed("stop", "", { stop: true });
|
||||
return { action: "unsupported", target: "", supported: false, summary: raw || trText("Unsupported action"), raw };
|
||||
}
|
||||
|
||||
// RustBox writes exceptions as `allof (<conditions>, not anyof (<exceptions>))`.
|
||||
function splitSieveExceptions(condition) {
|
||||
const raw = String(condition || "").trim();
|
||||
const group = raw.match(/^allof\s*\(([\s\S]*)\)$/i);
|
||||
if (!group) return { main: raw, exceptions: [] };
|
||||
const parts = splitSieveTopLevel(group[1]);
|
||||
const exception = parts.length === 2 ? parts[1].match(/^not\s+anyof\s*\(([\s\S]*)\)$/i) : null;
|
||||
if (!exception) return { main: raw, exceptions: [] };
|
||||
return { main: parts[0], exceptions: splitSieveTopLevel(exception[1]) };
|
||||
}
|
||||
|
||||
function parseSieveRules(script = $("#sieve-editor")?.value || "") {
|
||||
@@ -356,7 +485,19 @@ function parseSieveRules(script = $("#sieve-editor")?.value || "") {
|
||||
const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i);
|
||||
const enabled = !disabledMatch;
|
||||
const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim();
|
||||
const condition = parseSieveConditionExpression(actualCondition);
|
||||
const split = splitSieveExceptions(actualCondition);
|
||||
const condition = parseSieveConditionExpression(split.main);
|
||||
condition.exceptions = split.exceptions.map(parseSieveConditionToken);
|
||||
if (condition.exceptions.length) {
|
||||
const exceptionTrees = condition.exceptions.map(sieveConditionTree);
|
||||
condition.tree = condition.tree && exceptionTrees.every(Boolean)
|
||||
? { op: "allof", args: [condition.tree, { not: { op: "anyof", args: exceptionTrees } }] }
|
||||
: null;
|
||||
}
|
||||
if (condition.exceptions.length) {
|
||||
condition.supported = condition.supported && condition.exceptions.every((item) => item.supported && !item.negate);
|
||||
condition.summary = `${condition.summary} — ${trText("except if")} ${condition.exceptions.map((item) => item.summary).join(` ${trText("OR")} `)}`;
|
||||
}
|
||||
const body = source.slice(openBrace + 1, closeBrace);
|
||||
const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement);
|
||||
const stop = parsedActions.some((action) => action.stop);
|
||||
@@ -415,10 +556,12 @@ function setSieveDynamicAction(row, action) {
|
||||
function showSieveRuleDialog(mode = "new", ruleName = "") {
|
||||
const dialog = $("#sieve-rule-dialog");
|
||||
if (!dialog) return;
|
||||
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter";
|
||||
sieveApplyFolderOptions($("#sieve-apply-folder"), state.folder);
|
||||
resetSieveApplyPanel();
|
||||
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `${trText("Edit filter")} · ${ruleName}` : trText("Create filter");
|
||||
$("#sieve-rule-dialog-subtitle").textContent = mode === "edit"
|
||||
? "Change the rule visually; the live preview shows exactly what will be written to Sieve."
|
||||
: "Build the rule visually and verify the preview before applying it.";
|
||||
? trText("Change the rule visually; the live preview shows exactly what will be written to Sieve.")
|
||||
: trText("Build the rule visually and verify the preview before applying it.");
|
||||
if (!dialog.open) dialog.showModal();
|
||||
}
|
||||
|
||||
@@ -431,6 +574,7 @@ function openNewSieveRuleDialog() {
|
||||
function loadSieveRuleIntoBuilder(rule) {
|
||||
if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning");
|
||||
state.sieveEditingRuleId = rule.id;
|
||||
fillSieveKindSelects();
|
||||
$("#sieve-builder-name").value = rule.name;
|
||||
$("#sieve-builder-enabled").checked = rule.enabled;
|
||||
$("#sieve-builder-stop").checked = rule.stop;
|
||||
@@ -454,6 +598,7 @@ function loadSieveRuleIntoBuilder(rule) {
|
||||
setSieveDynamicAction(row, action);
|
||||
$("#sieve-builder-extra-actions").append(row);
|
||||
}
|
||||
$("#sieve-builder-exceptions")?.replaceChildren(...(rule.condition.exceptions || []).map((item) => createSieveExceptionRow(item)));
|
||||
$("#sieve-builder-replace").disabled = false;
|
||||
updateSieveBuilder();
|
||||
renderManagedSieveRules();
|
||||
@@ -466,6 +611,8 @@ function clearSieveRuleBuilder() {
|
||||
$("#sieve-builder-name").value = "";
|
||||
$("#sieve-builder-enabled").checked = true;
|
||||
$("#sieve-builder-stop").checked = true;
|
||||
$("#sieve-builder-exceptions")?.replaceChildren();
|
||||
fillSieveKindSelects();
|
||||
$("#sieve-builder-condition").value = "from";
|
||||
$("#sieve-builder-match").value = "contains";
|
||||
$("#sieve-builder-value").value = "";
|
||||
@@ -490,8 +637,8 @@ function clearSieveRuleBuilder() {
|
||||
updateSieveBuilder();
|
||||
renderManagedSieveRules();
|
||||
if ($("#sieve-rule-dialog")?.open) {
|
||||
$("#sieve-rule-dialog-title").textContent = "Create filter";
|
||||
$("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it.";
|
||||
$("#sieve-rule-dialog-title").textContent = trText("Create filter");
|
||||
$("#sieve-rule-dialog-subtitle").textContent = trText("Build the rule visually and verify the preview before applying it.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,21 +698,24 @@ function renderManagedSieveRules() {
|
||||
const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`);
|
||||
const main = el("div", "sieve-managed-rule-main");
|
||||
const title = el("div", "sieve-managed-rule-title");
|
||||
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF"));
|
||||
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, trText(rule.enabled ? "ON" : "OFF")));
|
||||
const summary = el("div", "sieve-managed-rule-summary");
|
||||
const when = el("div"); when.innerHTML = `<b>When:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
|
||||
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed";
|
||||
const then = el("div"); then.innerHTML = `<b>Then:</b> ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`;
|
||||
const when = el("div"); when.innerHTML = `<b>${escapeHtml(trText("When"))}:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
|
||||
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : trText("No action parsed");
|
||||
const then = el("div"); then.innerHTML = `<b>${escapeHtml(trText("Then"))}:</b> ${escapeHtml(actionText)}${rule.stop ? `; ${escapeHtml(trText("Stop processing more rules").toLowerCase())}` : ""}`;
|
||||
summary.append(when, then);
|
||||
main.append(title, summary);
|
||||
if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged."));
|
||||
if (!rule.editable) main.append(el("div", "sieve-rule-warning", trText("Uses syntax not fully supported by the visual editor; source is preserved unchanged.")));
|
||||
const actions = el("div", "sieve-managed-rule-actions");
|
||||
const edit = el("button", "button quiet small", "Edit"); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
|
||||
const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
|
||||
const edit = el("button", "button quiet small", trText("Edit")); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
|
||||
const toggle = el("button", "button quiet small", trText(rule.enabled ? "Disable" : "Enable")); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
|
||||
const up = el("button", "button quiet small", "↑"); up.type = "button"; up.title = "Move rule up"; up.disabled = index === 0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
|
||||
const down = el("button", "button quiet small", "↓"); down.type = "button"; down.title = "Move rule down"; down.disabled = index === rules.length - 1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
|
||||
const remove = el("button", "button quiet small danger", "Delete"); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule));
|
||||
actions.append(edit, toggle, up, down, remove);
|
||||
const remove = el("button", "button quiet small danger", trText("Delete")); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule));
|
||||
const runNow = el("button", "button quiet small", trText("Apply now")); runNow.type = "button"; runNow.disabled = !rule.editable;
|
||||
runNow.title = trText("Apply this rule to messages that are already in a folder");
|
||||
runNow.addEventListener("click", () => runParsedSieveRuleNow(rule));
|
||||
actions.append(edit, runNow, toggle, up, down, remove);
|
||||
item.append(main, actions);
|
||||
list.append(item);
|
||||
});
|
||||
@@ -573,12 +723,8 @@ function renderManagedSieveRules() {
|
||||
|
||||
function createSieveDynamicCondition() {
|
||||
const row = el("div", "sieve-dynamic-row sieve-dynamic-condition");
|
||||
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind"><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||||
const refresh = () => {
|
||||
const kind = row.querySelector(".sieve-dynamic-kind").value;
|
||||
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header");
|
||||
updateSieveBuilder();
|
||||
};
|
||||
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind">${sieveOptionsHtml(sieveConditionKinds)}</select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||||
const refresh = () => { syncSieveRowValueFields(row); updateSieveBuilder(); };
|
||||
row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh);
|
||||
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
|
||||
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
|
||||
@@ -588,31 +734,58 @@ function createSieveDynamicCondition() {
|
||||
function buildSieveDynamicCondition(row) {
|
||||
const condition = row.querySelector(".sieve-dynamic-kind").value;
|
||||
const value = row.querySelector(".sieve-dynamic-value").value.trim();
|
||||
if (!condition || !value) throw new Error("Every additional condition needs a value");
|
||||
const match = row.querySelector(".sieve-dynamic-match").value;
|
||||
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
|
||||
let test;
|
||||
let header = "";
|
||||
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
|
||||
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
|
||||
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
|
||||
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
|
||||
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
|
||||
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
|
||||
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
|
||||
else {
|
||||
header = row.querySelector(".sieve-dynamic-header").value.trim();
|
||||
if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid");
|
||||
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
|
||||
if (!condition || (!value && !valuelessSieveKinds.has(condition))) throw new Error(trText("Every additional condition needs a value"));
|
||||
const built = sieveTestFromParts({
|
||||
condition,
|
||||
match: row.querySelector(".sieve-dynamic-match").value,
|
||||
value,
|
||||
header: row.querySelector(".sieve-dynamic-header").value,
|
||||
negate: row.querySelector(".sieve-dynamic-negate").checked,
|
||||
}, "Additional condition");
|
||||
return { ...built, logic: row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof" };
|
||||
}
|
||||
|
||||
function createSieveExceptionRow(exception = null) {
|
||||
const row = el("div", "sieve-dynamic-row sieve-exception-row");
|
||||
row.innerHTML = `<label><span>${escapeHtml(trText("Except if"))}</span><select class="sieve-dynamic-kind">${sieveOptionsHtml(sieveConditionKinds)}</select></label><label><span>${escapeHtml(trText("Match"))}</span><select class="sieve-dynamic-match"><option value="contains">${escapeHtml(trText("contains"))}</option><option value="is">${escapeHtml(trText("is exactly"))}</option><option value="matches">${escapeHtml(trText("wildcard matches"))}</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>${escapeHtml(trText("Header name"))}</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label class="sieve-dynamic-value-wrap"><span>${escapeHtml(trText("Value"))}</span><input class="sieve-dynamic-value" placeholder="${escapeHtml(trText("value"))}"></label><button class="button quiet small sieve-dynamic-remove" type="button">${escapeHtml(trText("Remove"))}</button>`;
|
||||
if (exception) {
|
||||
row.querySelector(".sieve-dynamic-kind").value = exception.kind || "subject";
|
||||
row.querySelector(".sieve-dynamic-match").value = exception.match || "contains";
|
||||
row.querySelector(".sieve-dynamic-header").value = exception.kind === "header" ? exception.header || "" : "";
|
||||
row.querySelector(".sieve-dynamic-value").value = exception.value || "";
|
||||
} else {
|
||||
row.querySelector(".sieve-dynamic-kind").value = "subject";
|
||||
}
|
||||
const negate = row.querySelector(".sieve-dynamic-negate").checked;
|
||||
if (negate) test = `not ${test}`;
|
||||
return { test, condition, match, value, header, negate, logic: row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary: `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` };
|
||||
row.querySelector(".sieve-dynamic-kind").addEventListener("change", () => { syncSieveRowValueFields(row); updateSieveBuilder(); });
|
||||
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
|
||||
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
|
||||
syncSieveRowValueFields(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function syncSieveRowValueFields(row) {
|
||||
const kind = row.querySelector(".sieve-dynamic-kind").value;
|
||||
row.querySelector(".sieve-dynamic-header-wrap")?.classList.toggle("hidden", kind !== "header");
|
||||
const value = row.querySelector(".sieve-dynamic-value");
|
||||
const valueWrap = value?.closest("label");
|
||||
valueWrap?.classList.toggle("hidden", valuelessSieveKinds.has(kind));
|
||||
if (value) value.placeholder = sizeSieveKinds.has(kind) ? "2M" : trText("value");
|
||||
const match = row.querySelector(".sieve-dynamic-match");
|
||||
if (match) match.disabled = valuelessSieveKinds.has(kind) || sizeSieveKinds.has(kind);
|
||||
}
|
||||
|
||||
function buildSieveException(row) {
|
||||
return sieveTestFromParts({
|
||||
condition: row.querySelector(".sieve-dynamic-kind").value,
|
||||
match: row.querySelector(".sieve-dynamic-match").value,
|
||||
value: row.querySelector(".sieve-dynamic-value").value,
|
||||
header: row.querySelector(".sieve-dynamic-header").value,
|
||||
}, "Exception");
|
||||
}
|
||||
|
||||
function createSieveDynamicAction() {
|
||||
const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row");
|
||||
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action"><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||||
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action">${sieveOptionsHtml(sieveActionKinds)}</select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||||
const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); };
|
||||
row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh);
|
||||
row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder);
|
||||
@@ -626,12 +799,7 @@ function configureSieveDynamicAction(row) {
|
||||
const wrap = row.querySelector(".sieve-dynamic-target-wrap");
|
||||
const label = row.querySelector(".sieve-dynamic-target-label");
|
||||
const target = row.querySelector(".sieve-dynamic-target");
|
||||
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
|
||||
wrap.classList.toggle("hidden", !needsTarget);
|
||||
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
|
||||
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
|
||||
target.removeAttribute("list");
|
||||
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
|
||||
configureSieveTargetField(action, wrap, label, target);
|
||||
}
|
||||
|
||||
function buildSieveDynamicAction(row) {
|
||||
@@ -651,34 +819,39 @@ function buildSieveVisualRule() {
|
||||
const primary = buildSieveCondition("", true);
|
||||
const secondary = buildSieveCondition("-2", false);
|
||||
const tertiary = buildSieveCondition("-3", false);
|
||||
const requirements = new Set(primary.requirements);
|
||||
let test = primary.test;
|
||||
let summary = primary.summary;
|
||||
if (secondary) {
|
||||
const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof";
|
||||
test = `${logic} (${test}, ${secondary.test})`;
|
||||
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`;
|
||||
}
|
||||
if (tertiary) {
|
||||
const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof";
|
||||
test = `${logic} (${test}, ${tertiary.test})`;
|
||||
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`;
|
||||
}
|
||||
let tree = primary.tree;
|
||||
const join = (logic, next) => {
|
||||
test = `${logic} (${test}, ${next.test})`;
|
||||
summary = `${summary} ${trText(logic === "anyof" ? "OR" : "AND")} ${next.summary}`;
|
||||
tree = { op: logic, args: [tree, next.tree] };
|
||||
for (const requirement of next.requirements) requirements.add(requirement);
|
||||
};
|
||||
if (secondary) join($("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof", secondary);
|
||||
if (tertiary) join($("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof", tertiary);
|
||||
for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) {
|
||||
const extra = buildSieveDynamicCondition(row);
|
||||
test = `${extra.logic} (${test}, ${extra.test})`;
|
||||
summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`;
|
||||
join(extra.logic, extra);
|
||||
}
|
||||
const exceptions = $$("#sieve-builder-exceptions .sieve-exception-row").map(buildSieveException);
|
||||
if (exceptions.length) {
|
||||
test = `allof (${test}, not anyof (${exceptions.map((item) => item.test).join(", ")}))`;
|
||||
summary = `${summary} — ${trText("except if")} ${exceptions.map((item) => item.summary).join(` ${trText("OR")} `)}`;
|
||||
tree = { op: "allof", args: [tree, { not: { op: "anyof", args: exceptions.map((item) => item.tree) } }] };
|
||||
for (const item of exceptions) for (const requirement of item.requirements) requirements.add(requirement);
|
||||
}
|
||||
|
||||
const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean);
|
||||
const commands = actions.map((item) => item.command);
|
||||
const stop = $("#sieve-builder-stop").checked;
|
||||
if (stop) commands.push("stop;");
|
||||
const requirements = new Set();
|
||||
for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement);
|
||||
const conditionCode = enabled ? test : `false # ${test}`;
|
||||
return {
|
||||
name, enabled, stop, requirements: [...requirements], conditionSummary: summary,
|
||||
actionSummary: actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""),
|
||||
name, enabled, stop, requirements: [...requirements], conditionSummary: summary, tree, actions,
|
||||
actionSummary: actions.map((item) => item.summary).join("; ") + (stop ? `; ${trText("Stop processing more rules").toLowerCase()}` : ""),
|
||||
rule: `# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`,
|
||||
};
|
||||
}
|
||||
@@ -688,23 +861,33 @@ function configureSieveActionFields(suffix = "") {
|
||||
const wrap = $(`#sieve-builder-target-wrap${suffix}`);
|
||||
const label = $(`#sieve-builder-target-label${suffix}`);
|
||||
const target = $(`#sieve-builder-target${suffix}`);
|
||||
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
|
||||
configureSieveTargetField(action, wrap, label, target);
|
||||
}
|
||||
|
||||
function configureSieveTargetField(action, wrap, label, target) {
|
||||
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags", "label"].includes(action);
|
||||
wrap?.classList.toggle("hidden", !needsTarget);
|
||||
if (!target || !label) return;
|
||||
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
|
||||
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
|
||||
label.textContent = trText(["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : action === "label" ? "Label" : "Folder");
|
||||
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : action === "label" ? trText("Work") : "Archive";
|
||||
target.removeAttribute("list");
|
||||
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
|
||||
if (action === "label") target.setAttribute("list", "sieve-tag-options");
|
||||
}
|
||||
|
||||
function applySieveCapabilityMatrix(extensions) {
|
||||
const supports = (name) => extensions.has(name.toLowerCase());
|
||||
const actionAllowed = (action) => {
|
||||
if (["move", "copy"].includes(action) && !supports("fileinto")) return false;
|
||||
if (["move", "copy", "trash"].includes(action) && !supports("fileinto")) return false;
|
||||
if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false;
|
||||
if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false;
|
||||
if (["mark-read", "add-flag", "set-flags", "label"].includes(action) && !supports("imap4flags")) return false;
|
||||
return true;
|
||||
};
|
||||
const conditionAllowed = (condition) => condition !== "has-attachment" || !state.sieveInfo || supports("mime");
|
||||
for (const select of [$("#sieve-builder-condition"), $("#sieve-builder-condition-2"), $("#sieve-builder-condition-3"), ...$$("#sieve-rule-dialog .sieve-dynamic-kind")]) {
|
||||
if (!select) continue;
|
||||
for (const option of select.options) option.disabled = Boolean(option.value) && !conditionAllowed(option.value);
|
||||
};
|
||||
for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) {
|
||||
if (!select) continue;
|
||||
for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value);
|
||||
@@ -720,7 +903,7 @@ function renderSieveBuilderPreview() {
|
||||
if (!preview || !code || !statePill) return;
|
||||
try {
|
||||
const built = buildSieveVisualRule();
|
||||
preview.innerHTML = `<div><strong>When:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>Then:</strong> ${escapeHtml(built.actionSummary || "No action")}</div>`;
|
||||
preview.innerHTML = `<div><strong>${escapeHtml(trText("When"))}:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>${escapeHtml(trText("Then"))}:</strong> ${escapeHtml(built.actionSummary || trText("No action"))}</div>`;
|
||||
code.textContent = built.rule;
|
||||
statePill.textContent = built.enabled ? "Enabled" : "Disabled";
|
||||
statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`;
|
||||
@@ -735,12 +918,23 @@ function renderSieveBuilderPreview() {
|
||||
function updateSieveBuilder() {
|
||||
const condition = $("#sieve-builder-condition").value;
|
||||
$("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header");
|
||||
for (const suffix of ["", "-2", "-3"]) {
|
||||
const kind = $(`#sieve-builder-condition${suffix}`).value;
|
||||
const value = $(`#sieve-builder-value${suffix}`);
|
||||
value.closest("label")?.classList.toggle("hidden", valuelessSieveKinds.has(kind));
|
||||
if (sizeSieveKinds.has(kind)) value.placeholder = "2M";
|
||||
else value.placeholder = suffix ? trText("optional") : "example.com";
|
||||
if (!suffix) $("#sieve-builder-match").disabled = valuelessSieveKinds.has(kind) || sizeSieveKinds.has(kind);
|
||||
}
|
||||
$$("#sieve-rule-dialog .sieve-dynamic-condition, #sieve-rule-dialog .sieve-exception-row").forEach(syncSieveRowValueFields);
|
||||
const tagList = $("#sieve-tag-options");
|
||||
if (tagList) tagList.replaceChildren(...(state.tags || []).map((tag) => new Option(tag.name, tag.name)));
|
||||
|
||||
for (const suffix of ["-2", "-3"]) {
|
||||
const nextCondition = $(`#sieve-builder-condition${suffix}`).value;
|
||||
const enabled = Boolean(nextCondition);
|
||||
$(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header");
|
||||
$(`#sieve-builder-match${suffix}`).disabled = !enabled;
|
||||
$(`#sieve-builder-match${suffix}`).disabled = !enabled || valuelessSieveKinds.has(nextCondition) || sizeSieveKinds.has(nextCondition);
|
||||
$(`#sieve-builder-value${suffix}`).disabled = !enabled;
|
||||
$(`#sieve-builder-negate${suffix}`).disabled = !enabled;
|
||||
const customHeader = $(`#sieve-builder-header${suffix}`);
|
||||
@@ -769,10 +963,13 @@ function updateSieveBuilder() {
|
||||
.concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean));
|
||||
const requirements = new Set();
|
||||
for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement);
|
||||
const conditionKinds = [$("#sieve-builder-condition").value, $("#sieve-builder-condition-2").value, $("#sieve-builder-condition-3").value]
|
||||
.concat($$("#sieve-rule-dialog .sieve-dynamic-kind").map((node) => node.value));
|
||||
for (const kind of conditionKinds) for (const requirement of sieveBuilderRequirements(kind, "")) requirements.add(requirement);
|
||||
const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase()));
|
||||
$("#sieve-builder-capability").textContent = missing.length
|
||||
? `Server does not advertise: ${missing.join(", ")}`
|
||||
: requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required";
|
||||
? `${trText("Server does not advertise")}: ${missing.join(", ")}`
|
||||
: requirements.size ? `${trText("Requires")}: ${[...requirements].join(", ")}` : trText("No optional extension required");
|
||||
const vacationSupported = extensions.has("vacation");
|
||||
const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value);
|
||||
const scheduleSupported = extensions.has("date") && extensions.has("relational");
|
||||
@@ -835,6 +1032,7 @@ async function appendSieveVacation() {
|
||||
async function applySieveVisualRule(updateSelected) {
|
||||
try {
|
||||
const built = buildSieveVisualRule();
|
||||
if (!await ensureSieveTargetFolders(built.actions)) return;
|
||||
const editor = $("#sieve-editor");
|
||||
let body = editor.value;
|
||||
if (updateSelected) {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
// Filter conveniences on top of the visual Sieve editor: templates, creating
|
||||
// missing target folders, and "Apply now" — running a rule's actions on mail
|
||||
// that is already in a folder (Sieve itself only sees newly delivered mail).
|
||||
|
||||
function fmtText(template, values = {}) {
|
||||
return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- templates
|
||||
|
||||
const sieveRuleTemplates = [
|
||||
{ id: "sender-folder", label: "Move mail from a sender", name: "from_sender", condition: "from", match: "is", action: "move", focus: "value" },
|
||||
{ id: "subject-folder", label: "Subject contains words", name: "subject_words", condition: "subject", match: "contains", action: "move", focus: "value" },
|
||||
{ id: "vip-flag", label: "Flag mail from a VIP", name: "vip", condition: "from", match: "is", action: "add-flag", target: "\\Flagged", stop: false, focus: "value" },
|
||||
{ id: "newsletters", label: "Newsletters to a folder", name: "newsletters", condition: "list-id", match: "matches", value: "*", action: "move", target: "Newsletters", focus: "target" },
|
||||
{ id: "alias-folder", label: "Mail sent to an alias", name: "alias", condition: "to-only", match: "is", action: "move", focus: "value" },
|
||||
{ id: "large-attachments", label: "Large attachments", name: "large_attachments", condition: "has-attachment", second: { condition: "size-over", value: "10M" }, action: "label", focus: "target" },
|
||||
{ id: "forward-copy", label: "Forward a copy", name: "forward_copy", condition: "from", match: "contains", action: "redirect-copy", stop: false, focus: "value" },
|
||||
];
|
||||
|
||||
function renderSieveTemplates() {
|
||||
const host = $("#sieve-templates");
|
||||
if (!host || host.childElementCount) return;
|
||||
for (const template of sieveRuleTemplates) {
|
||||
const chip = el("button", "sieve-template-chip", trText(template.label));
|
||||
chip.type = "button";
|
||||
chip.dataset.template = template.id;
|
||||
chip.addEventListener("click", () => applySieveTemplate(template));
|
||||
host.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function applySieveTemplate(template) {
|
||||
clearSieveRuleBuilder();
|
||||
$("#sieve-builder-name").value = template.name;
|
||||
$("#sieve-builder-stop").checked = template.stop !== false;
|
||||
$("#sieve-builder-condition").value = template.condition;
|
||||
$("#sieve-builder-match").value = template.match || "contains";
|
||||
$("#sieve-builder-value").value = template.value || "";
|
||||
if (template.second) {
|
||||
$("#sieve-builder-condition-2").value = template.second.condition;
|
||||
$("#sieve-builder-value-2").value = template.second.value || "";
|
||||
$("#sieve-builder-logic").value = "allof";
|
||||
}
|
||||
$("#sieve-builder-action").value = template.action;
|
||||
$("#sieve-builder-target").value = template.target || "";
|
||||
updateSieveBuilder();
|
||||
resetSieveApplyPanel();
|
||||
const focus = template.focus === "target" ? $("#sieve-builder-target") : $("#sieve-builder-value");
|
||||
setTimeout(() => focus?.focus(), 20);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- missing folders
|
||||
|
||||
function folderExists(name) {
|
||||
return state.folders.some((folder) => folder.name === name);
|
||||
}
|
||||
|
||||
// Offers to create move/copy targets that do not exist yet. Returns false
|
||||
// when the user cancels.
|
||||
async function ensureSieveTargetFolders(actions) {
|
||||
const missing = [...new Set(actions
|
||||
.filter((item) => ["move", "copy"].includes(item.action) && item.target)
|
||||
.map((item) => item.target)
|
||||
.filter((name) => !folderExists(name)))];
|
||||
if (!missing.length) return true;
|
||||
const list = missing.map((name) => `“${name}”`).join(", ");
|
||||
const create = await appDialog({
|
||||
title: trText("Folder does not exist"),
|
||||
message: fmtText("The rule moves mail to {folders}, which does not exist yet. Create it now?", { folders: list }),
|
||||
confirmLabel: trText("Create folder"),
|
||||
cancelLabel: trText("Keep rule as is"),
|
||||
});
|
||||
if (!create) return true;
|
||||
try {
|
||||
for (const name of missing) await request("/folders", { method: "POST", body: JSON.stringify({ name }) });
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
toast(fmtText("Created {folders}", { folders: list }), "success");
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- apply now
|
||||
|
||||
const SIEVE_APPLY_CONCURRENCY = 4;
|
||||
const SIEVE_LABEL_CHUNK = 200;
|
||||
|
||||
async function matchSieveRule(folder, tree) {
|
||||
return request("/filters/match", { method: "POST", body: JSON.stringify({ folder, rule: tree }) });
|
||||
}
|
||||
|
||||
// Splits a rule's actions into what can be done to existing messages.
|
||||
function planSieveActions(actions) {
|
||||
const plan = { flags: new Set(), labels: [], copies: [], move: null, skipped: [] };
|
||||
for (const item of actions) {
|
||||
if (item.action === "mark-read") plan.flags.add("\\Seen");
|
||||
else if (item.action === "add-flag") sieveFlagValues(item.target).forEach((flag) => plan.flags.add(flag));
|
||||
else if (item.action === "label") {
|
||||
const tag = (state.tags || []).find((entry) => entry.id === item.tagId || entry.name === item.target);
|
||||
if (tag) plan.labels.push(tag);
|
||||
else plan.skipped.push(item.summary);
|
||||
} else if (item.action === "copy" && item.target) plan.copies.push(item.target);
|
||||
else if (["move", "trash"].includes(item.action) && item.target) plan.move = item.target;
|
||||
else if (!["keep", "stop"].includes(item.action)) plan.skipped.push(item.summary);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function sievePlanSummary(plan) {
|
||||
const parts = [];
|
||||
if (plan.flags.size) parts.push(`${trText("set flags")} ${[...plan.flags].join(", ")}`);
|
||||
for (const tag of plan.labels) parts.push(`${trText("Add label")} “${tag.name}”`);
|
||||
for (const target of plan.copies) parts.push(`${trText("Copy to")} “${target}”`);
|
||||
if (plan.move) parts.push(`${trText("Move to")} “${plan.move}”`);
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
async function runWithConcurrency(items, worker, onProgress) {
|
||||
let next = 0;
|
||||
let done = 0;
|
||||
const failures = [];
|
||||
const lane = async () => {
|
||||
while (next < items.length) {
|
||||
const item = items[next++];
|
||||
try { await worker(item); } catch (error) { failures.push(error); }
|
||||
done += 1;
|
||||
onProgress?.(done);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(SIEVE_APPLY_CONCURRENCY, items.length) }, lane));
|
||||
return failures;
|
||||
}
|
||||
|
||||
// Applies a plan to `uids` in `folder`: flags and labels first, then copies,
|
||||
// then the single move, so every step still finds the message in place.
|
||||
async function executeSievePlan(folder, uids, plan, onProgress) {
|
||||
const steps = (plan.flags.size ? 1 : 0) + plan.labels.length + plan.copies.length + (plan.move && plan.move !== folder ? 1 : 0);
|
||||
const total = Math.max(1, steps * uids.length);
|
||||
let base = 0;
|
||||
const progress = (done) => onProgress?.(Math.min(total, base + done), total);
|
||||
const failures = [];
|
||||
if (plan.flags.size) {
|
||||
const flags = [...plan.flags];
|
||||
failures.push(...await runWithConcurrency(uids, (uid) => request(`/messages/${encodeURIComponent(folder)}/${uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags, add: true }) }), progress));
|
||||
base += uids.length;
|
||||
}
|
||||
for (const tag of plan.labels) {
|
||||
for (let index = 0; index < uids.length; index += SIEVE_LABEL_CHUNK) {
|
||||
const chunk = uids.slice(index, index + SIEVE_LABEL_CHUNK);
|
||||
try {
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: chunk.map((uid) => ({ uid, folder })) }) });
|
||||
} catch (error) { failures.push(error); }
|
||||
progress(Math.min(uids.length, index + chunk.length));
|
||||
}
|
||||
base += uids.length;
|
||||
}
|
||||
for (const target of plan.copies) {
|
||||
if (target === folder) { base += uids.length; continue; }
|
||||
failures.push(...await runWithConcurrency(uids, (uid) => request("/messages/copy", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: target, uid }) }), progress));
|
||||
base += uids.length;
|
||||
}
|
||||
if (plan.move && plan.move !== folder) {
|
||||
const release = suppressRealtimeNotificationsForFolder(plan.move);
|
||||
try {
|
||||
failures.push(...await runWithConcurrency(uids, (uid) => request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: plan.move, uid }) }), progress));
|
||||
} finally { release?.(); }
|
||||
}
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
await refreshCurrentMessageView();
|
||||
return { failures: failures.length, firstError: failures[0]?.message || "" };
|
||||
}
|
||||
|
||||
function sieveMatchDescription(result, folder) {
|
||||
let text = fmtText("{count} messages in “{folder}” match this rule.", { count: result.matched, folder });
|
||||
if (result.uncertain) text += ` ${fmtText("{count} more could not be checked exactly and will be left untouched.", { count: result.uncertain })}`;
|
||||
if (result.truncated) text += ` ${fmtText("Only the first {count} will be processed.", { count: result.uids.length })}`;
|
||||
return text;
|
||||
}
|
||||
|
||||
// Shared confirmation + execution for "Apply now" everywhere.
|
||||
async function applySieveRuleToFolder({ folder, tree, actions, result = null, onStatus = null }) {
|
||||
const status = (text) => { onStatus?.(text); };
|
||||
const plan = planSieveActions(actions);
|
||||
const summary = sievePlanSummary(plan);
|
||||
if (!summary) {
|
||||
toast(trText("None of this rule's actions can be applied to existing messages"), "warning");
|
||||
return null;
|
||||
}
|
||||
const match = result || await matchSieveRule(folder, tree);
|
||||
if (!match.uids.length) {
|
||||
status(sieveMatchDescription(match, folder));
|
||||
toast(fmtText("No messages in “{folder}” match this rule", { folder }), "info");
|
||||
return match;
|
||||
}
|
||||
let message = `${sieveMatchDescription(match, folder)}\n\n${trText("Actions")}: ${summary}.`;
|
||||
if (plan.skipped.length) message += `\n${trText("Skipped for existing messages")}: ${plan.skipped.join("; ")}.`;
|
||||
const confirmed = await appConfirm(message, { title: trText("Apply rule to existing messages"), confirmLabel: fmtText("Apply to {count} messages", { count: match.uids.length }) });
|
||||
if (!confirmed) return match;
|
||||
if (plan.move && !folderExists(plan.move) && !await ensureSieveTargetFolders([{ action: "move", target: plan.move }])) return match;
|
||||
status(fmtText("Applying… {done}/{total}", { done: 0, total: match.uids.length }));
|
||||
const outcome = await executeSievePlan(folder, match.uids, plan, (done, total) => status(fmtText("Applying… {done}/{total}", { done, total })));
|
||||
const applied = fmtText("Rule applied to {count} messages", { count: match.uids.length });
|
||||
if (outcome.failures) {
|
||||
status(`${applied} · ${fmtText("{count} operations failed", { count: outcome.failures })}: ${outcome.firstError}`);
|
||||
toast(`${applied} · ${fmtText("{count} operations failed", { count: outcome.failures })}`, "warning");
|
||||
} else {
|
||||
status(applied);
|
||||
toast(applied, "success");
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function sieveApplyFolderOptions(select, preferred) {
|
||||
if (!select) return;
|
||||
const current = select.value;
|
||||
select.replaceChildren(...orderedFolders().map((folder) => new Option(folder.name, folder.name)));
|
||||
const wanted = current || preferred || state.folder || inboxFolder();
|
||||
select.value = folderExists(wanted) ? wanted : inboxFolder();
|
||||
}
|
||||
|
||||
function resetSieveApplyPanel() {
|
||||
state.sieveApplyPreview = null;
|
||||
const run = $("#sieve-apply-run");
|
||||
if (run) run.disabled = true;
|
||||
const result = $("#sieve-apply-result");
|
||||
if (result) result.textContent = "";
|
||||
}
|
||||
|
||||
function sieveApplyKey(built, folder) {
|
||||
return JSON.stringify({ folder, tree: built.tree, actions: built.actions.map((item) => [item.action, item.target]) });
|
||||
}
|
||||
|
||||
async function checkSieveApplyMatches() {
|
||||
const result = $("#sieve-apply-result");
|
||||
const button = $("#sieve-apply-check");
|
||||
const folder = $("#sieve-apply-folder").value;
|
||||
resetSieveApplyPanel();
|
||||
let built;
|
||||
try { built = buildSieveVisualRule(); } catch (error) { result.textContent = error.message; return; }
|
||||
setBusy(button, true);
|
||||
result.textContent = trText("Checking…");
|
||||
try {
|
||||
const match = await matchSieveRule(folder, built.tree);
|
||||
state.sieveApplyPreview = { key: sieveApplyKey(built, folder), match };
|
||||
const plan = planSieveActions(built.actions);
|
||||
let text = sieveMatchDescription(match, folder);
|
||||
if (plan.skipped.length) text += ` ${trText("Skipped for existing messages")}: ${plan.skipped.join("; ")}.`;
|
||||
if (!sievePlanSummary(plan)) text += ` ${trText("None of this rule's actions can be applied to existing messages")}.`;
|
||||
result.textContent = text;
|
||||
$("#sieve-apply-run").disabled = !match.uids.length || !sievePlanSummary(plan);
|
||||
} catch (error) {
|
||||
result.textContent = error.message;
|
||||
} finally { setBusy(button, false); }
|
||||
}
|
||||
|
||||
async function runSieveApplyNow() {
|
||||
const folder = $("#sieve-apply-folder").value;
|
||||
const result = $("#sieve-apply-result");
|
||||
let built;
|
||||
try { built = buildSieveVisualRule(); } catch (error) { result.textContent = error.message; return; }
|
||||
const preview = state.sieveApplyPreview;
|
||||
if (!preview || preview.key !== sieveApplyKey(built, folder)) {
|
||||
resetSieveApplyPanel();
|
||||
result.textContent = trText("The rule changed — check matches again.");
|
||||
return;
|
||||
}
|
||||
const button = $("#sieve-apply-run");
|
||||
setBusy(button, true);
|
||||
try {
|
||||
await applySieveRuleToFolder({ folder, tree: built.tree, actions: built.actions, result: preview.match, onStatus: (text) => { result.textContent = text; } });
|
||||
} catch (error) {
|
||||
result.textContent = error.message;
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
state.sieveApplyPreview = null;
|
||||
button.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function runParsedSieveRuleNow(rule) {
|
||||
const tree = rule.condition?.tree;
|
||||
if (!rule.editable || !tree) {
|
||||
toast(trText("This rule uses Sieve syntax that cannot be applied to existing messages"), "warning");
|
||||
return;
|
||||
}
|
||||
const folder = await appSelect(trText("Apply rule to existing messages"), orderedFolders().map((item) => ({ value: item.name, label: item.name })), {
|
||||
message: fmtText("Choose the folder whose messages “{rule}” should be applied to.", { rule: rule.name }),
|
||||
value: folderExists(state.folder) ? state.folder : inboxFolder(),
|
||||
confirmLabel: trText("Check matches"),
|
||||
});
|
||||
if (!folder) return;
|
||||
try {
|
||||
await applySieveRuleToFolder({ folder, tree, actions: rule.actions });
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
// -------------------------------------------- quick filters from a message
|
||||
|
||||
function quickFilterTree(config) {
|
||||
const kind = config.kind === "domain" ? "from-domain" : config.kind === "subject" ? "subject" : "from";
|
||||
return { test: { kind, match: config.match === "contains" ? "contains" : "is", value: config.value, header: "" } };
|
||||
}
|
||||
|
||||
function quickFilterActions(config) {
|
||||
const actions = [];
|
||||
if (config.action === "move" || config.action === "copy") actions.push({ action: config.action, target: config.target, summary: sieveActionSummary(config.action, config.target) });
|
||||
else if (config.action === "mark-read") actions.push({ action: "mark-read", target: "", summary: sieveActionSummary("mark-read") });
|
||||
else if (config.action === "flag") actions.push({ action: "add-flag", target: "\\Flagged", summary: sieveActionSummary("add-flag", "\\Flagged") });
|
||||
else if (config.action === "discard") actions.push({ action: "discard", target: "", summary: sieveActionSummary("discard") });
|
||||
if (config.markRead && config.action !== "mark-read") actions.unshift({ action: "mark-read", target: "", summary: sieveActionSummary("mark-read") });
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Appends a rule to a Sieve script (creating it when needed) and validates it
|
||||
// on the server before saving.
|
||||
async function appendRuleToSieveScript(built, scriptName, activate) {
|
||||
const scripts = state.sieveScripts || [];
|
||||
const existing = scripts.find((item) => item.name === scriptName);
|
||||
let current = "";
|
||||
if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(scriptName)}`, { method: "GET" })).content || "";
|
||||
const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
|
||||
const validation = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content }) });
|
||||
if (!validation.valid) throw new Error(validation.error || trText("Filter validation failed"));
|
||||
await request(`/sieve/scripts/${encodeURIComponent(scriptName)}`, { method: "PUT", body: JSON.stringify({ content }) });
|
||||
if (activate || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(scriptName)}/activate`, { method: "POST", body: "{}" });
|
||||
}
|
||||
|
||||
async function loadSieveStatusForQuickFilter() {
|
||||
const [sieveInfo, scripts] = await Promise.all([
|
||||
request("/sieve/status", { method: "GET" }),
|
||||
request("/sieve/scripts", { method: "GET" }),
|
||||
]);
|
||||
state.sieveInfo = sieveInfo;
|
||||
state.sieveScripts = scripts.scripts || [];
|
||||
return sieveInfo;
|
||||
}
|
||||
|
||||
const NEW_FOLDER_CHOICE = "\u0000new";
|
||||
|
||||
// "Always move messages from this sender…" — one rule, optionally applied to
|
||||
// what is already in the current folder.
|
||||
async function alwaysMoveFromSender(message) {
|
||||
if (state.authPolicy.sieveEnabled === false) return;
|
||||
const sender = String(message?.from_address || "").trim();
|
||||
if (!sender) return toast(trText("This message has no sender address"), "warning");
|
||||
const sourceFolder = message.folder || state.folder;
|
||||
try { await loadSieveStatusForQuickFilter(); } catch (error) { return toast(`${trText("ManageSieve unavailable")}: ${error.message}`, "error"); }
|
||||
const folders = orderedFolders().filter((item) => item.name !== sourceFolder).map((item) => ({ value: item.name, label: item.name }));
|
||||
folders.push({ value: NEW_FOLDER_CHOICE, label: trText("New folder…") });
|
||||
let target = await appSelect(trText("Always move messages from this sender"), folders, {
|
||||
message: fmtText("New mail from {sender} will be moved automatically to:", { sender }),
|
||||
value: detectSpecialFolder("archive") || folders[0]?.value || "",
|
||||
confirmLabel: trText("Continue"),
|
||||
});
|
||||
if (!target) return;
|
||||
if (target === NEW_FOLDER_CHOICE) {
|
||||
target = String(await appDialog({ title: trText("New folder"), input: { value: "", required: true, placeholder: trText("Folder name") }, confirmLabel: trText("Create folder") }) || "").trim();
|
||||
if (!target) return;
|
||||
try {
|
||||
await request("/folders", { method: "POST", body: JSON.stringify({ name: target }) });
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
} catch (error) { return toast(error.message, "error"); }
|
||||
}
|
||||
const config = { kind: "sender", match: "is", value: sender, action: "move", target, stop: true };
|
||||
const scripts = state.sieveScripts || [];
|
||||
const scriptName = scripts.find((item) => item.active)?.name || scripts.find((item) => item.name === "rustbox")?.name || "rustbox";
|
||||
try {
|
||||
await appendRuleToSieveScript(buildQuickFilterRule(config), scriptName, !scripts.some((item) => item.active));
|
||||
toast(fmtText("Filter added to {script}", { script: scriptName }), "success");
|
||||
} catch (error) { return toast(error.message, "error"); }
|
||||
try {
|
||||
await applySieveRuleToFolder({ folder: sourceFolder, tree: quickFilterTree(config), actions: quickFilterActions(config) });
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function bindSieveApplyUi() {
|
||||
renderSieveTemplates();
|
||||
$("#sieve-builder-add-exception")?.addEventListener("click", () => {
|
||||
$("#sieve-builder-exceptions").append(createSieveExceptionRow());
|
||||
updateSieveBuilder();
|
||||
});
|
||||
$("#sieve-apply-check")?.addEventListener("click", checkSieveApplyMatches);
|
||||
$("#sieve-apply-run")?.addEventListener("click", runSieveApplyNow);
|
||||
$("#sieve-apply-folder")?.addEventListener("change", resetSieveApplyPanel);
|
||||
const dialog = $("#sieve-rule-dialog");
|
||||
dialog?.addEventListener("input", (event) => { if (!event.target.closest(".sieve-apply-now")) resetSieveApplyPanel(); });
|
||||
dialog?.addEventListener("click", (event) => { if (event.target.closest(".sieve-dynamic-remove")) resetSieveApplyPanel(); });
|
||||
}
|
||||
@@ -425,6 +425,10 @@ function bindEvents() {
|
||||
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
|
||||
$("#pref-skin-depth").addEventListener("change", (event) => savePreference("skinDepth", event.target.value));
|
||||
$("#pref-skin-gradients").addEventListener("change", (event) => savePreference("skinGradients", event.target.checked));
|
||||
$("#pref-theme-light-from").addEventListener("change", (event) => { if (event.target.value) savePreference("themeLightFrom", event.target.value); });
|
||||
$("#pref-theme-dark-from").addEventListener("change", (event) => { if (event.target.value) savePreference("themeDarkFrom", event.target.value); });
|
||||
$("#pref-account-color-enabled").addEventListener("change", (event) => savePreference("accountColor", event.target.checked ? ($("#pref-account-color").value || "#4d75e6") : ""));
|
||||
$("#pref-account-color").addEventListener("input", (event) => { if ($("#pref-account-color-enabled").checked) savePreference("accountColor", event.target.value); });
|
||||
$("#pref-preset-scale-unlocked").addEventListener("change", (event) => savePreference("presetScaleUnlocked", event.target.checked));
|
||||
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
|
||||
$("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value));
|
||||
@@ -597,6 +601,9 @@ function bindEvents() {
|
||||
$("#contact-compose").addEventListener("click", composeToContact);
|
||||
$("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); });
|
||||
$("#calendar-form").addEventListener("submit", saveCalendarEvent);
|
||||
fillCalendarReminderSelects();
|
||||
$("#calendar-default-reminder").addEventListener("change", () => saveCalendarReminderDefault("default_reminder_minutes", "#calendar-default-reminder"));
|
||||
$("#calendar-default-reminder-extra").addEventListener("change", () => saveCalendarReminderDefault("default_extra_reminder_minutes", "#calendar-default-reminder-extra"));
|
||||
$("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click());
|
||||
$("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files));
|
||||
$("#calendar-export").addEventListener("click", exportCalendarIcs);
|
||||
@@ -636,6 +643,8 @@ function bindEvents() {
|
||||
$("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog);
|
||||
$("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); });
|
||||
$("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); });
|
||||
fillSieveKindSelects();
|
||||
bindSieveApplyUi();
|
||||
$("#sieve-vacation-start").addEventListener("change", updateSieveBuilder);
|
||||
$("#sieve-vacation-end").addEventListener("change", updateSieveBuilder);
|
||||
$("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false));
|
||||
@@ -659,11 +668,11 @@ function bindEvents() {
|
||||
$("#sieve-delete").addEventListener("click", deleteSieve);
|
||||
bindEditor();
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("#context-menu")) hideContextMenu();
|
||||
if (!event.target.closest("#context-menu, #context-submenu")) hideContextMenu();
|
||||
if (!event.target.closest(".toolbar-menu, .toolbar-menu-popover[data-toolbar-menu-owner]")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
|
||||
});
|
||||
window.addEventListener("blur", hideContextMenu);
|
||||
document.addEventListener("scroll", hideContextMenu, true);
|
||||
document.addEventListener("scroll", (event) => { if (!event.target?.closest?.(".context-menu")) hideContextMenu(); }, true);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const tag = document.activeElement?.tagName || "";
|
||||
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
|
||||
@@ -715,6 +724,11 @@ function bindEvents() {
|
||||
else if (state.prefs.theme === "system") { applyPreferences(); if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail); }
|
||||
});
|
||||
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
|
||||
setInterval(() => {
|
||||
if (state.prefs.theme !== "schedule" || root.dataset.theme === scheduledTheme()) return;
|
||||
applyPreferences();
|
||||
if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail);
|
||||
}, 60000);
|
||||
window.addEventListener("popstate", async (event) => {
|
||||
const full = event.state?.rustboxMailFullscreen === true;
|
||||
if (!full) {
|
||||
|
||||
@@ -10,4 +10,5 @@
|
||||
72-openpgp-identities.js
|
||||
80-settings-realtime.js
|
||||
90-sieve.js
|
||||
91-sieve-apply.js
|
||||
95-bindings-boot.js
|
||||
Reference in new issue
Block a user