From 83440f20e2d71547defc3d8dd291f16b7fe9d001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 2 Nov 2026 13:54:18 +0100 Subject: [PATCH] v0.2.12 --- CHANGELOG.md | 38 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 6 +- RELEASE-MANIFEST.txt | 25 +- RELEASE-VALIDATION.txt | 22 +- config.example.toml | 2 +- docs/IMPLEMENTATION_STATUS.md | 21 +- docs/RELEASING.md | 2 +- docs/STORAGE_SEARCH_OPERATIONS.md | 4 +- migrations/README.md | 4 +- packaging/rustbox.toml | 2 +- scripts/validate-release-static.py | 41 +- src/routes/drafts.rs | 773 ++++++++++++++++++++++++++--- src/routes/mod.rs | 4 + src/routes/send.rs | 41 ++ src/smtp/builder.rs | 2 +- src/web/css/30-mail.css | 22 +- src/web/css/40-compose.css | 136 ++++- src/web/i18n/en.json | 2 +- src/web/i18n/pl.json | 12 +- src/web/index.html | 35 +- src/web/js/00-bootstrap.js | 1 + src/web/js/40-messages-reader.js | 112 ++++- src/web/js/50-compose.js | 732 ++++++++++++++++++++++++--- src/web/js/80-settings-realtime.js | 13 + src/web/js/95-bindings-boot.js | 80 ++- 28 files changed, 1925 insertions(+), 213 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa39f26..c95fad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # RustBox changelog +### 0.2.12 + +- Saving or updating a draft no longer raises a realtime “new message” toast/system notification for the Drafts folder; the folder/list still refreshes normally, and IMAP snapshots remain `\Draft` + `\Seen`. +- Added a composer `Delete draft` action that permanently removes the current IMAP draft and cascades cleanup through the local autosave row, draft attachment rows and uploaded attachment blobs; IMAP deletion is verified before local state is reported as deleted. +- IMAP-only drafts opened from other mail clients can be deleted without requiring a pre-existing RustBox draft database row, while UIDVALIDITY and `\\Draft`/Drafts-folder checks prevent deleting a stale or non-draft message by mistake. +- Explicit `Save draft` now records the newly appended IMAP UID in the live composer even for a brand-new draft, so later Save/Delete/Send operations consistently target the current server snapshot. +- Draft messages in the configured IMAP Drafts folder (or carrying the `\\Draft` flag) now open directly in the normal composer from their server-side RFC822/MIME content; opening does not depend on a RustBox draft database row or `X-RustBox-Draft-ID`, so drafts created by other mail clients can be edited and sent. +- Editing an IMAP-sourced draft keeps its original server UID as the replacement source: explicit Save draft appends the new MIME snapshot first and removes the superseded IMAP draft only after the new UID is resolved; sending also removes the source draft after successful SMTP delivery. +- Existing IMAP draft attachments stay usable directly from their MIME parts while editing, can be removed/re-embedded without first copying the whole message into the draft database, and are carried into Save/Send together with newly uploaded local attachments. +- Inline MIME parts now preserve their original filenames through `Content-Disposition: inline; filename=...` when saving drafts and sending messages, improving Roundcube/Thunderbird interoperability. +- IMAP draft reopening restores To/Cc/Bcc, Subject, plain/HTML body, reply threading headers, priority, MDN state, From identity when it matches a configured identity, inline CIDs and attachment filenames directly from the server message. +- Fixed manual IMAP draft saving for drafts without To/Cc/Bcc recipients by using a build-only envelope while keeping recipient headers empty; Bcc is retained in saved draft snapshots. +- IMAP draft saving now honors the per-account Special folders → Drafts mapping and `default_drafts_folder`, then falls back to the IMAP `\Drafts` attribute and common `Drafts`/`Draft` folder names. +- Added an explicit `Save draft` composer action immediately before `Cancel`; it forces the current message and attachments to be persisted as a real IMAP `\Draft` snapshot while leaving background autosave behavior unchanged. +- Manual IMAP draft saves now report a failure when RFC822 snapshot creation or IMAP APPEND fails instead of showing a misleading success state. +- Fixed draft recovery so attachment metadata is restored from both the browser recovery snapshot and the authoritative server draft. +- Composer attachment chips now show every uploaded file, including inline images/links, instead of hiding embedded items from the attachment list. +- Adding another attachment now merges by unique attachment ID and refreshes the server-side draft attachment list, preventing an existing chip from appearing to be replaced. +- Inline attachment chips expose an attachment-only action that removes the inline occurrence without deleting the uploaded file. +- Stretched `Load more` and `Load all` across the full message-list action area with equal-width controls. +- Added a composer `Hide fields` / `Show fields` toggle that collapses From/To/Cc/Bcc/Subject/options so the editor can use the freed vertical space; invalid recipient input automatically expands the fields again. +- Fixed inline image insertion so adding another image or attachment appends a new element instead of replacing the current selection. +- Replaced deprecated `execCommand("insertHTML")` attachment insertion with DOM Range insertion and safe caret placement. +- Removed filename/size attachment de-duplication so separate files with identical metadata can still be attached independently. +- Moved inline-image resize/alignment controls into a floating contextual panel so selecting an image no longer reduces the composer editing area. +- Added mouse/touch resize handles around the selected inline image while preserving its aspect ratio. +- Inline images can be dragged to another caret position inside the message body. +- Pasting PNG/JPEG/GIF/WebP files with Ctrl/Cmd+V embeds them inline, and dropping files into the editor uploads them at the drop position. +- Attachment chips explicitly identify regular/image attachments, while selected embedded images are labelled `Inline image`. +- Made attachment re-embed actions compact icon buttons to keep the attachment strip readable. +- Collapsed large To/Cc recipient lists in the message reader into a compact summary with on-demand expansion. +- Added a `Load all` action beside `Load more` in the message list. +- Renamed the reader toolbar action from `Message details` to `Details`. +- Images added to HTML messages now embed inline by default; removing an inline image keeps the underlying file available as an attachment that can be embedded again. +- Added `Embed` actions for attachments: images are inserted inline and other files can be inserted as CID links in the message body. +- Added quick inline-image sizing and alignment controls, plus the existing advanced crop/resize editor and an explicit `Keep as attachment` action. +- No schema migration is required; V049 remains the next migration number. + ### 0.2.11 - No schema migration is required; V049 remains the next migration number. diff --git a/Cargo.lock b/Cargo.lock index 3cab4a3..8279c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.11" +version = "0.2.12" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 455746c..b632e06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.11" +version = "0.2.12" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 08f2e3c..b639b69 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.11 is derived from the Oxi webmail project: +RustBox 0.2.12 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 11f1c68..ea78155 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.11 +# RustBox 0.2.12 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. @@ -34,7 +34,7 @@ rustbox/ `build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`. -## Storage and search in 0.2.11 +## Storage and search in 0.2.12 RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared. @@ -235,7 +235,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.2.11 release" +git commit -m "RustBox 0.2.12 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index c7216b6..5ee94a0 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,6 +1,23 @@ -RustBox 0.2.11 release manifest +RustBox 0.2.12 release manifest -0.2.11 feature/configuration changes: +0.2.12 feature/configuration changes: +- suppresses realtime “new message” toast/system notifications for Drafts saves while preserving normal folder/list refresh and `\Draft` + `\Seen` IMAP flags +- adds `Delete draft` in the composer; IMAP deletion is UID/UIDVALIDITY-checked and local draft attachment rows/blobs are cascaded after the server draft is removed +- adds an explicit `Save draft` action before `Cancel`; it persists the current message and attachments as a real IMAP Drafts snapshot without changing background autosave behavior +- explicit IMAP draft saves surface snapshot/APPEND failures instead of reporting a false success +- draft recovery restores attachment metadata from browser snapshots plus the authoritative server draft +- attachment chips show all uploaded files, including inline images/links, and new uploads merge by unique attachment ID instead of replacing visible entries +- fixes multi-attachment/inline-image insertion so new items append instead of replacing the selected image/content +- uses DOM Range insertion with stable caret placement for embedded images and CID file links +- floats image sizing/alignment actions over the editor instead of consuming composer height +- supports Ctrl/Cmd+V image embedding, file drop at the caret, dragging embedded images within the body, and four resize handles +- labels embedded images separately from regular/image attachment chips +- keeps attachment re-embed actions compact and allows separate same-name/same-size files +- compact recipient address summaries with expandable full recipient lists +- stretches `Load more` and `Load all` evenly across the full message-list action width +- adds a composer `Hide fields` / `Show fields` control so From/To/Cc/Bcc/Subject/options can collapse and the editor can use the freed vertical space +- Load all message-list action and shorter Details label +- inline-by-default image attachments, re-embedding, CID file links, and inline image layout controls - opens message-body links without an explicit target in a new tab/window instead of navigating inside the sandboxed mail-preview iframe; explicit targets remain intact - shows the login success toast only after a real POST login; session restoration/page refresh is silent - improves non-ASCII/Polish mail display with explicit UTF-8 preview documents and broader RFC 2047 header decoding (including ISO-8859-2/Windows-1250) @@ -44,14 +61,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.11 configuration +- README.md documents 0.2.12 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.11 +- Cargo.toml version = 0.2.12 - 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 diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 385876d..07515d7 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,6 +1,18 @@ -RustBox 0.2.11 - final source validation +RustBox 0.2.12 - final source validation -0.2.11 scope: +0.2.12 scope: +- saving/updating a draft stays silent in realtime: Drafts still refreshes, but no “new message” toast/system notification is emitted +- composer `Delete draft` removes the current verified IMAP draft and cascades local draft attachment metadata/blob cleanup +- explicit Save draft action before Cancel forces a real IMAP Drafts snapshot while background autosave behavior remains unchanged +- manual IMAP save reports APPEND/snapshot failures instead of showing false success +- draft recovery restores attachment metadata and the attachment strip keeps inline plus regular files visible together +- new uploads merge against the full server-side draft attachment list so adding another file cannot replace an existing visible entry +- composer UX regression fixed: image actions are contextual/floating instead of a dedicated layout row +- multiple inline images/attachments append independently and do not replace the active selection +- same-name/same-size files are no longer silently de-duplicated +- recipient list collapsing, full-width Load more/Load all controls, and reader Details label +- composer fields can collapse to maximize the message editor, and invalid recipient input expands them again +- inline attachment embedding/re-embedding, clipboard/file drop insertion, image drag repositioning, and pointer resize handles - message-body links without an explicit target open outside the sandboxed preview in a new tab/window, while explicit targets are preserved - page refresh/session restoration does not emit a false “Successfully logged in” toast - real login still emits the success toast @@ -9,15 +21,15 @@ RustBox 0.2.11 - final source validation - 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 - no runtime schema migration is added; V049 remains next -- version metadata updated to 0.2.11 +- version metadata updated to 0.2.12 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (351/351 checks passed) +- offline release validator: scripts/validate-release-static.py (374/374 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.11 +- release metadata consistently identifies 0.2.12 - V048 migration files/catalog registration and SQLite upgrade execution are validated offline diff --git a/config.example.toml b/config.example.toml index e907d0b..82c404a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.11 +# RustBox 0.2.12 host = "127.0.0.1" port = 3001 environment = "production" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 391fcdd..b0b9ac9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,8 +1,19 @@ -# RustBox 0.2.11 - implementation status +# RustBox 0.2.12 - implementation status -This file tracks the `0.2.11` 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. +- 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.12` 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.11 browser mail UX +## 0.2.12 browser mail UX + +- Large To/Cc lists collapse to a compact recipient summary and expand on demand. +- Message pagination exposes both `Load more` and `Load all`; both actions stretch evenly across the full list width, and the reader menu label is shortened to `Details`. +- The composer can collapse From/To/Cc/Bcc/Subject/options from its header so the editor receives the freed vertical space; invalid recipient input automatically reopens the fields. +- The composer exposes an explicit `Save draft` action before `Cancel`; it forces a real IMAP Drafts snapshot with the current MIME body and attachments while background autosave remains local/server-working-state only. +- The composer exposes `Delete draft` for IMAP and recovered drafts; deletion verifies the current IMAP UID/UIDVALIDITY, expunges the server draft first, then removes the local autosave row, cascaded attachment rows and uploaded attachment blobs. +- HTML composing embeds newly added PNG/JPEG/GIF/WebP images inline by default while preserving deleted inline content as reusable attachments. +- Attachment chips can re-embed images or insert CID links for other files, and selected inline images expose quick size/alignment controls plus the advanced crop editor. +- Clipboard image paste and file drag/drop insert at the current/drop caret; already embedded images can be dragged to a new body position. +- Selected inline images expose four pointer resize handles that preserve aspect ratio, and attachment chips distinguish image attachments from ordinary files. - Message-body links without an explicit `target` default to `target="_blank"` so external navigation opens outside the sandboxed mail-preview iframe; explicit targets are preserved and `_blank` links receive `noopener noreferrer`. @@ -79,7 +90,7 @@ This file tracks the `0.2.11` source snapshot. Storage/search scope and outstand - HTML, plain-text and Markdown compose modes. Markdown keeps its source text for the plain alternative and generates escaped/sanitized HTML for preview and delivery without accepting raw Markdown HTML. - Expanded HTML toolbar: bold, italic, underline, strike, lists, quote, alignment, links, font, size, color, clear formatting, undo/redo. -- Draft autosave, explicit recovery of saved/browser-emergency drafts, and attachment upload/delete. Deliberate IMAP Drafts snapshots carry a stable RustBox draft identifier; the previous remote snapshot is removed only after the new UID is resolved unambiguously and UIDVALIDITY still matches. +- Draft autosave, explicit recovery of saved/browser-emergency drafts, and attachment upload/delete. IMAP Drafts messages are also first-class editable drafts: RustBox opens their RFC822/MIME content directly from IMAP (including external-client drafts), stages only the normal autosave/recovery state locally while editing, and replaces the source UID only after the new snapshot is appended and resolved safely. `X-RustBox-Draft-ID` remains an internal UID-resolution aid for RustBox-created snapshots, not a prerequisite for opening or editing a draft. - Drag-and-drop attachments with visible upload progress. Attachment uploads stream multipart chunks through `BlobStorage` with an enforced size limit instead of buffering the whole attachment in memory; filesystem and S3-compatible backends share the same logical-object flow. - Inline image upload using CID attachments, including paste and drag/drop of PNG/JPEG/GIF/WebP images into the composer. During editing, CID images use authenticated same-origin draft-attachment preview URLs and are serialized back to `cid:` references for saved/sent MIME content. Inline images can be resized, aligned left/center/right/inline and cropped in-app (GIF cropping is intentionally disabled to preserve animation); display-only resizing does not rewrite the source file. Unused CID attachments are cleaned when their image is removed from the editor. - Attachment reminder for common English/Polish wording. @@ -150,7 +161,7 @@ This file tracks the `0.2.11` source snapshot. Storage/search scope and outstand ## Partially implemented - keep visible as incomplete - Sieve visual builder now supports a three-condition nested AND/OR group, negation, two actions, capability-aware disabling, managed-rule ordering and scheduled vacation dates. An arbitrary-depth condition tree, arbitrary-length action stack and full capability-aware condition/action matrix remain unfinished. -- Drafts are stored locally and can be recovered after interrupted editing/SMTP failure. Remote replacement is implemented conservatively: RustBox resolves the new UID with UIDNEXT plus an exact per-draft header search and only removes the previous snapshot when resolution and UIDVALIDITY are safe; wider real-server compatibility still needs the release gate. +- Composer autosave/recovery state is stored locally, while deliberately saved drafts are standard RFC822/MIME messages in the configured IMAP Drafts folder. IMAP drafts from RustBox or another client open directly in the composer; replacement is conservative (append/resolve first, then remove the prior UID after UIDVALIDITY verification). Wider real-server compatibility still needs the release gate. - Calendar is local/ICS-capable and stores recurrence/attendee/reminder metadata, but recurrence expansion/edit semantics, invitation responses and CalDAV synchronization are not finished. - Notifications are wired through IMAP IDLE/WebSocket, include permission status/test controls, and local calendar reminders can show browser notifications and play the configured sound while RustBox is open. Service-worker/background delivery is still not finished. - Identities emit Organization metadata; composer priority, MDN request headers and validated administrator-defined outbound headers are wired. SMTP DSN negotiation remains to be completed. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b216865..1253254 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source. -## Updating an existing checkout to 0.2.11 +## Updating an existing checkout to 0.2.12 Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files. diff --git a/docs/STORAGE_SEARCH_OPERATIONS.md b/docs/STORAGE_SEARCH_OPERATIONS.md index 79be3e7..61f5a92 100644 --- a/docs/STORAGE_SEARCH_OPERATIONS.md +++ b/docs/STORAGE_SEARCH_OPERATIONS.md @@ -1,6 +1,6 @@ -# RustBox 0.2.11 storage/search operations +# RustBox 0.2.12 storage/search operations -RustBox 0.2.11 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. +RustBox 0.2.12 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. ## Deployment matrix diff --git a/migrations/README.md b/migrations/README.md index 667b444..bab6ac4 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,6 +1,6 @@ # Database schemas and migrations -RustBox 0.2.11 keeps SQL, blob storage and search as independent concerns: +RustBox 0.2.12 keeps SQL, blob storage and search as independent concerns: ```text database_backend = sqlite | mysql | pgsql @@ -37,7 +37,7 @@ Account data is isolated inside the selected physical database with a determinis Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path. -RustBox 0.2.11 does not add a runtime schema migration; **V049** remains the next migration number. +RustBox 0.2.12 does not add a runtime schema migration; **V049** remains the next migration number. Redis and Redis Sentinel do not use SQL migrations. diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index 319c6f6..a3cf1e5 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.11 - packaged production configuration (DEB/RPM) +# RustBox 0.2.12 - packaged production configuration (DEB/RPM) # # This file contains all supported RustBox configuration options. # Commented options are optional and do not change the packaged defaults. diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index b40c32f..87a5e89 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static/offline release checks for RustBox 0.2.11. +"""Static/offline release checks for RustBox 0.2.12. These checks intentionally avoid pretending to replace `cargo test`: they validate configuration, SQL migrations, Docker examples, query-catalog parity and release @@ -49,14 +49,14 @@ def extract_section(text: str, begin: str, end: str) -> str: # Release/toolchain metadata. cargo = tomllib.loads(read("Cargo.toml")) -require(cargo["package"]["version"] == "0.2.11", "Cargo package version is 0.2.11") +require(cargo["package"]["version"] == "0.2.12", "Cargo package version is 0.2.12") require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1") toolchain = tomllib.loads(read("rust-toolchain.toml")) require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1") require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds") lock = tomllib.loads(read("Cargo.lock")) lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {}) -require(lock_rustbox.get("version") == "0.2.11", "Cargo.lock RustBox package version is 0.2.11") +require(lock_rustbox.get("version") == "0.2.12", "Cargo.lock RustBox package version is 0.2.12") require(cargo["dependencies"].get("encoding_rs") == "0.8", "direct charset decoder dependency is pinned") require("encoding_rs" in lock_rustbox.get("dependencies", []), "Cargo.lock records direct charset decoder dependency") require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing") @@ -71,27 +71,54 @@ require("make_zip.py" in package_script and "sha256sum" in package_script, "rele require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks") require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate") require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate") -require(read("README.md").startswith("# RustBox 0.2.11"), "README identifies RustBox 0.2.11") -require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.11"), "release manifest identifies RustBox 0.2.11") -require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.11"), "release validation identifies RustBox 0.2.11") -require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.11"), "implementation status identifies RustBox 0.2.11") +require(read("README.md").startswith("# RustBox 0.2.12"), "README identifies RustBox 0.2.12") +require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.12"), "release manifest identifies RustBox 0.2.12") +require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.12"), "release validation identifies RustBox 0.2.12") +require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.12"), "implementation status identifies RustBox 0.2.12") bootstrap_js = read("src/web/js/00-bootstrap.js") bindings_js = read("src/web/js/95-bindings-boot.js") realtime_js = read("src/web/js/80-settings-realtime.js") mail_actions_js = read("src/web/js/60-mail-actions-search.js") auth_js = read("src/web/js/20-preferences-auth.js") reader_js = read("src/web/js/40-messages-reader.js") +compose_js = read("src/web/js/50-compose.js") +compose_css = read("src/web/css/40-compose.css") +mail_css = read("src/web/css/30-mail.css") require("perPage: 300" in bootstrap_js and "messages_per_page: 300" in bootstrap_js, "browser mailbox default is 300 messages") require(re.search(r"default_per_page\(\)\s*->\s*u32\s*\{\s*300\s*\}", read("src/routes/messages/types.rs")) is not None, "folder message API default page size is 300") require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API accepts up to 300 messages per page") require('20..=300' in read("src/db/mail_preferences.rs"), "mailbox preference accepts 300 messages per page") require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts") require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present") +require('realtimeFolderIsDrafts' in realtime_js and 'if (realtimeFolderIsDrafts(data?.folder)) return true;' in realtime_js, "realtime new-mail notifications are suppressed for Drafts folders") +require('const draftsFolder = syncImap' in compose_js and 'suppressRealtimeNotificationsForFolder(draftsFolder)' in compose_js and 'releaseRealtimeSuppression();' in compose_js, "manual IMAP draft save suppresses its own realtime new-mail notification") require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed") require('finishAuthenticatedStartup({ showLoginToast = false } = {})' in auth_js and 'finishAuthenticatedStartup({ showLoginToast: true })' in auth_js and bindings_js.count('finishAuthenticatedStartup();') >= 1, "login success toast is limited to real login, not refresh/session restoration") require('${csp}' in reader_js, "message preview srcdoc explicitly declares UTF-8") require('function sanitizeMessagePreviewHtml(html)' in bootstrap_js and 'if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");' in bootstrap_js and '!["_self", "_parent", "_top"].includes(target)' in bootstrap_js and 'rel.add("noopener");' in bootstrap_js and 'rel.add("noreferrer");' in bootstrap_js and 'sanitizeMessagePreviewHtml(detail.html)' in reader_js, "message preview links default to safe new-window navigation without overriding explicit targets") require('attachmentContentUrl(folder, uid, attachment)' in reader_js and 'frame.src = attachmentContentUrl(folder, uid, attachment);' in reader_js and 'URL.createObjectURL(blob)' in reader_js, "PDF preview uses authenticated inline URL while other attachment previews retain blob handling") +require('function composeAttachmentInsertionRange' in compose_js and 'target.collapse(false);' in compose_js and 'range.insertNode(inserted);' in compose_js and 'document.execCommand("insertHTML"' not in compose_js, "compose attachment embedding uses non-destructive DOM Range insertion") +require('position: fixed;' in compose_css and '.compose-image-actions' in compose_css and 'grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto;' in compose_css, "inline image actions float without consuming a composer grid row") +require('function beginInlineImageResize' in compose_js and 'data-image-resize="nw"' in read("src/web/index.html") and '.compose-image-resize-handle' in compose_css, "selected inline images expose pointer resize handles") +require('application/x-rustbox-inline-image' in bindings_js and 'function moveInlineImageToRange' in compose_js, "inline images can be repositioned by drag and drop") +require('clipboardData' in bindings_js and 'uploadInlineImage(images, range)' in bindings_js, "clipboard images are uploaded and embedded inline") +require('.compose-attachment-kind' in compose_css and 'Image attachment' in compose_js and 'Inline image' in compose_js, "composer distinguishes embedded images from regular attachments") +require("function mergeComposeAttachments" in compose_js and "await refreshComposeAttachmentsFromServer(id);" in compose_js and "attachments: mergeComposeAttachments(local.attachments, server.attachments)" in compose_js, "composer preserves multiple attachments and draft recovery merges authoritative server attachment metadata") +require("const attachments = mergeComposeAttachments(state.composeAttachments);" in compose_js and "Inline link" in compose_js and "detachComposeAttachmentFromBody" in compose_js, "composer attachment strip keeps inline and regular attachments visible together") +require('grid-template-columns: repeat(2, minmax(0, 1fr));' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message load actions share the full available list width") +require('id="compose-fields-toggle"' in read("src/web/index.html") and 'function setComposeFieldsCollapsed' in compose_js and 'function toggleComposeFields' in compose_js and '.compose-dialog.compose-fields-collapsed .compose-addresses' in compose_css and 'setComposeFieldsCollapsed(false)' in bindings_js, "composer addressing fields can collapse to maximize editor space and reopen on invalid recipient input") +require('id="compose-save-draft"' in read("src/web/index.html") and '$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);' in bindings_js and 'saveCurrentDraft(false, true, true)' in compose_js, "composer exposes an explicit manual IMAP draft save before cancel") +require('id="compose-delete-draft"' in read("src/web/index.html") and '$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);' in bindings_js and 'function deleteComposeDraft()' in compose_js and 'imap_uid_validity' in compose_js, "composer exposes permanent draft deletion with current IMAP coordinates") +require('if req.sync_imap {' in read("src/routes/drafts.rs") and 'Failed to save draft to IMAP' in read("src/routes/drafts.rs") and 'AppError::ServiceUnavailable' in read("src/routes/drafts.rs"), "manual IMAP draft save fails visibly when the IMAP snapshot cannot be appended") +drafts_rs = read("src/routes/drafts.rs") +require('.keep_bcc()' in drafts_rs and 'if valid_recipients == 0 {' in drafts_rs and 'builder = builder.envelope(envelope);' in drafts_rs, "IMAP draft snapshots allow recipient-less drafts and preserve Bcc headers") +require('db::special_folders::get_role(conn, "drafts")?' in drafts_rs and 'default_drafts_folder' in drafts_rs and 'find_folder_by_attribute(conn, "\\\\Drafts")?' in drafts_rs, "manual IMAP draft save honors configured and detected Drafts folders") +require('for common_name in ["Drafts", "Draft"]' in drafts_rs, "manual IMAP draft save recognizes common Draft and Drafts folder names") +require('get_imap_draft_handler' in drafts_rs and '/messages/{folder}/{uid}/draft-compose' in read("src/routes/mod.rs") and 'fetch_body(&creds, &folder, uid)' in drafts_rs, "saved IMAP drafts reopen from RFC822/MIME without a local draft-row dependency") +require('delete_imap_draft_source' in drafts_rs and 'Incomplete IMAP draft deletion coordinates' in drafts_rs and 'let mut attachments_deleted = 0usize;' in drafts_rs, "draft deletion verifies the IMAP source and cascades local attachment cleanup") +require('messageLooksLikeImapDraft' in reader_js and 'await openImapDraftComposer(folder, uid);' in reader_js and 'state.composeRemoteDraft = draft.imap_source' in compose_js, "IMAP Drafts messages open directly in the normal composer") +require('imap_source' in compose_js and 'imap_attachments' in compose_js and 'load_imap_source_attachments' in drafts_rs and 'remove_imap_source_snapshot' in drafts_rs, "IMAP draft edits preserve remote MIME attachments and replace the source snapshot safely") +require('Attachment::new_inline_with_name' in drafts_rs and 'Attachment::new_inline_with_name' in read("src/smtp/builder.rs"), "inline MIME parts preserve attachment filenames in saved drafts and sent messages") require('mail_parser::MessageParser::default().parse(synthetic.as_bytes())' in read("src/imap/parse.rs") and 'decode_header_bytes' in read("src/imap/parse.rs"), "RFC 2047 header decoding uses mail-parser and tolerant envelope byte handling") require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'decodes_polish_rfc2047_subjects' in read("src/imap/parse.rs"), "Polish UTF-8/ISO-8859-2/Windows-1250 header decoding is covered") require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only") diff --git a/src/routes/drafts.rs b/src/routes/drafts.rs index 52db832..625dd35 100644 --- a/src/routes/drafts.rs +++ b/src/routes/drafts.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use axum::extract::Path as AxumPath; +use axum::extract::{Path as AxumPath, Query}; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::auth::session::SessionState; use crate::config::AppConfig; @@ -13,9 +14,23 @@ use crate::imap::client::{ImapClient, ImapCredentials}; use crate::smtp::client::AttachmentData; use crate::storage::{self, BlobStorage}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImapDraftSource { + pub folder: String, + pub uid: u32, + pub uid_validity: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImapDraftAttachmentRef { + pub id: String, + pub index: usize, +} + #[derive(Debug, Deserialize)] pub struct UpsertDraftRequest { pub id: String, + pub from_identity_id: Option, #[serde(default)] pub to: String, #[serde(default)] @@ -46,6 +61,10 @@ pub struct UpsertDraftRequest { #[serde(default)] pub sync_imap: bool, + + pub imap_source: Option, + #[serde(default)] + pub imap_attachments: Vec, } fn default_priority() -> String { @@ -63,6 +82,78 @@ fn default_pgp_mode() -> String { struct DraftResponse { id: String, status: String, + remote_folder: Option, + remote_uid: Option, + remote_uid_validity: Option, + remote_attachments: Vec, +} + +#[derive(Debug, Serialize)] +struct ImapDraftAttachmentInfo { + id: String, + source_index: usize, + source_content_id: Option, + filename: String, + content_type: String, + size: usize, + checksum_sha256: String, + source: &'static str, +} + +#[derive(Debug, Serialize)] +struct ImapDraftComposeResponse { + to: String, + cc: String, + bcc: String, + subject: String, + text_body: String, + html_body: Option, + in_reply_to: Option, + references: Option, + request_mdn: bool, + priority: String, + compose_format: String, + from_address: String, + imap_source: ImapDraftSource, + attachments: Vec, +} + +fn imap_attachment_infos( + attachments: &[crate::imap::types::ImapAttachment], +) -> Vec { + let mut used_ids = std::collections::HashSet::new(); + attachments + .iter() + .enumerate() + .map(|(index, attachment)| { + let checksum = format!("{:x}", Sha256::digest(&attachment.data)); + let source_content_id = attachment.content_id.clone(); + let base_id = source_content_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("imap-{index}-{}", &checksum[..12])); + let mut id = base_id.clone(); + let mut suffix = 1usize; + while !used_ids.insert(id.clone()) { + id = format!("{base_id}-{suffix}"); + suffix += 1; + } + ImapDraftAttachmentInfo { + id, + source_index: index, + source_content_id, + filename: attachment + .filename + .clone() + .unwrap_or_else(|| format!("attachment-{}", index + 1)), + content_type: attachment.content_type.clone(), + size: attachment.size, + checksum_sha256: checksum, + source: "imap", + } + }) + .collect() } #[derive(Debug, Serialize)] @@ -109,6 +200,39 @@ struct DraftListItem { #[derive(Debug, Serialize)] struct DeleteResponse { status: String, + remote_deleted: bool, + attachments_deleted: usize, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DeleteDraftQuery { + pub imap_folder: Option, + pub imap_uid: Option, + pub imap_uid_validity: Option, +} + +impl DeleteDraftQuery { + fn imap_source(&self) -> Result, AppError> { + match ( + self.imap_folder.as_deref(), + self.imap_uid, + self.imap_uid_validity, + ) { + (None, None, None) => Ok(None), + (Some(folder), Some(uid), Some(uid_validity)) + if !folder.trim().is_empty() && uid > 0 && uid_validity > 0 => + { + Ok(Some(ImapDraftSource { + folder: folder.to_string(), + uid, + uid_validity, + })) + } + _ => Err(AppError::BadRequest( + "Incomplete IMAP draft deletion coordinates".to_string(), + )), + } + } } pub async fn upsert_draft_handler( @@ -165,69 +289,188 @@ pub async fn upsert_draft_handler( ) .map_err(AppError::InternalError)?; - if req.sync_imap && !session.imap_host.trim().is_empty() { + let mut remote_folder = None; + let mut remote_uid = None; + let mut remote_uid_validity = None; + let mut remote_attachments = Vec::new(); + + if req.sync_imap { + if session.imap_host.trim().is_empty() { + return Err(AppError::ServiceUnavailable( + "IMAP is not available for saving this draft".to_string(), + )); + } + let imap_creds = session_imap_credentials(&session); - let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts") - .ok() - .flatten() - .unwrap_or_else(|| "Drafts".to_string()); + let drafts_folder = + resolve_drafts_folder(&conn, &config).map_err(AppError::InternalError)?; + tracing::debug!(folder = %drafts_folder, draft_id = %req.id, "Resolved IMAP drafts folder"); let previous = db::drafts::get_draft(&conn, &req.id).map_err(AppError::InternalError)?; + let mut snapshot_attachments = load_snapshot_attachments(&conn, &req.id, storage.as_ref()) + .await + .map_err(|e| { + tracing::warn!(error = %e, draft_id = %req.id, "Failed to load attachments for IMAP draft snapshot"); + AppError::InternalError(e) + })?; + if let Some(source) = req.imap_source.as_ref() { + let mut remote_attachments = load_imap_source_attachments( + imap_client.as_ref(), + &imap_creds, + source, + &req.imap_attachments, + ) + .await?; + snapshot_attachments.append(&mut remote_attachments); + } + let selected_identity = if let Some(identity_id) = req.from_identity_id { + Some( + db::identities::get_identity(&conn, identity_id) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("Identity not found".to_string()))?, + ) + } else { + db::identities::get_default_identity(&conn).map_err(AppError::InternalError)? + }; + let from_email = selected_identity + .as_ref() + .map(|identity| identity.email.as_str()) + .unwrap_or(session.email.as_str()); + let from_header = selected_identity + .as_ref() + .map(|identity| { + if identity.display_name.trim().is_empty() { + identity.email.clone() + } else { + let name = identity + .display_name + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!("\"{name}\" <{}>", identity.email) + } + }) + .unwrap_or_else(|| session.email.clone()); + let rfc822_bytes = build_draft_rfc822( + &req, + &from_header, + from_email, + &snapshot_attachments, + ) + .map_err(|e| { + tracing::warn!(error = %e, draft_id = %req.id, "Failed to build RFC822 draft snapshot"); + AppError::InternalError(format!("Failed to build IMAP draft: {e}")) + })?; + let (new_uid, uid_validity) = imap_client + .append_message_resolved_uid( + &imap_creds, + &drafts_folder, + &rfc822_bytes, + &["\\Draft", "\\Seen"], + "X-RustBox-Draft-ID", + &req.id, + ) + .await + .map_err(|e| { + tracing::warn!(error = %e, folder = %drafts_folder, draft_id = %req.id, "Failed to save draft snapshot to IMAP"); + AppError::ServiceUnavailable(format!("Failed to save draft to IMAP: {e}")) + })?; - let snapshot_attachments = - load_snapshot_attachments(&conn, &req.id, storage.as_ref()).await; - match snapshot_attachments - .and_then(|attachments| build_draft_rfc822(&req, &session.email, &attachments)) + db::drafts::set_remote_location(&conn, &req.id, &drafts_folder, new_uid, uid_validity) + .map_err(AppError::InternalError)?; + + remote_folder = Some(drafts_folder.clone()); + remote_uid = Some(new_uid); + remote_uid_validity = Some(uid_validity); + match imap_client + .fetch_body(&imap_creds, &drafts_folder, new_uid) + .await { - Ok(rfc822_bytes) => match imap_client - .append_message_resolved_uid( - &imap_creds, - &drafts_folder, - &rfc822_bytes, - &["\\Draft", "\\Seen"], - "X-RustBox-Draft-ID", - &req.id, - ) - .await - { - Ok((new_uid, uid_validity)) => { - db::drafts::set_remote_location( - &conn, - &req.id, - &drafts_folder, - new_uid, - uid_validity, - ) - .map_err(AppError::InternalError)?; + Ok(body) => remote_attachments = imap_attachment_infos(&body.attachments), + Err(error) => tracing::warn!( + error = %error, + folder = %drafts_folder, + uid = new_uid, + "Saved IMAP draft but could not refresh its attachment map" + ), + } - if let Some(previous) = previous { - remove_previous_remote_snapshot( - imap_client.as_ref(), - &imap_creds, - &previous, - &drafts_folder, - new_uid, - uid_validity, - ) - .await; - } - } - Err(e) => { - tracing::warn!(error = %e, folder = %drafts_folder, draft_id = %req.id, "Failed to resolve IMAP UID for saved draft snapshot; keeping previous remote pointer"); - } - }, - Err(e) => { - tracing::warn!(error = %e, draft_id = %req.id, "Failed to build RFC822 draft snapshot") - } + let previous_matches_source = previous.as_ref().is_some_and(|draft| { + req.imap_source.as_ref().is_some_and(|source| { + draft.remote_folder.as_deref() == Some(source.folder.as_str()) + && draft.remote_uid == Some(source.uid) + && draft.remote_uid_validity == Some(source.uid_validity) + }) + }); + + if let Some(source) = req.imap_source.as_ref() { + remove_imap_source_snapshot( + imap_client.as_ref(), + &imap_creds, + source, + &drafts_folder, + new_uid, + ) + .await; + } + + if let Some(previous) = previous.filter(|_| !previous_matches_source) { + remove_previous_remote_snapshot( + imap_client.as_ref(), + &imap_creds, + &previous, + &drafts_folder, + new_uid, + uid_validity, + ) + .await; } } Ok(Json(DraftResponse { id: req.id, status: "saved".to_string(), + remote_folder, + remote_uid, + remote_uid_validity, + remote_attachments, }) .into_response()) } +fn resolve_drafts_folder( + conn: &crate::db::pool::Connection, + config: &AppConfig, +) -> Result { + if let Some(folder) = db::special_folders::get_role(conn, "drafts")? { + return Ok(folder); + } + + if let Some(folder) = config + .default_drafts_folder + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + && db::folders::get_folder(conn, folder)?.is_some() + { + return Ok(folder.to_string()); + } + + if let Some(folder) = db::folders::find_folder_by_attribute(conn, "\\Drafts")? { + return Ok(folder); + } + + let folders = db::folders::get_all_folders(conn)?; + for common_name in ["Drafts", "Draft"] { + if let Some(folder) = folders + .iter() + .find(|folder| folder.name.eq_ignore_ascii_case(common_name)) + { + return Ok(folder.name.clone()); + } + } + + Ok("Drafts".to_string()) +} + fn session_imap_credentials(session: &SessionState) -> ImapCredentials { ImapCredentials { host: session.imap_host.clone(), @@ -239,6 +482,100 @@ fn session_imap_credentials(session: &SessionState) -> ImapCredentials { } } +pub(crate) async fn load_imap_source_attachments( + imap_client: &dyn ImapClient, + creds: &ImapCredentials, + source: &ImapDraftSource, + refs: &[ImapDraftAttachmentRef], +) -> Result, AppError> { + if refs.is_empty() { + return Ok(Vec::new()); + } + let status = imap_client + .folder_status(creds, &source.folder) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + if status.uid_validity != source.uid_validity { + return Err(AppError::BadRequest( + "The source draft changed on the IMAP server; reopen it before saving".to_string(), + )); + } + let body = imap_client + .fetch_body(creds, &source.folder, source.uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + let mut seen = std::collections::HashSet::new(); + let mut attachments = Vec::with_capacity(refs.len()); + for reference in refs { + if reference.id.is_empty() + || reference.id.len() > 255 + || reference.id.chars().any(|ch| matches!(ch, '\r' | '\n')) + || !seen.insert(reference.index) + { + return Err(AppError::BadRequest( + "Invalid IMAP draft attachment reference".to_string(), + )); + } + let attachment = body.attachments.get(reference.index).ok_or_else(|| { + AppError::BadRequest( + "An attachment in the source draft changed; reopen the draft before saving" + .to_string(), + ) + })?; + attachments.push(AttachmentData { + filename: attachment + .filename + .clone() + .unwrap_or_else(|| format!("attachment-{}", reference.index + 1)), + content_type: attachment.content_type.clone(), + data: attachment.data.clone(), + content_id: Some(reference.id.clone()), + }); + } + Ok(attachments) +} + +pub(crate) async fn remove_imap_source_snapshot( + imap_client: &dyn ImapClient, + creds: &ImapCredentials, + source: &ImapDraftSource, + new_folder: &str, + new_uid: u32, +) { + if source.folder == new_folder && source.uid == new_uid { + return; + } + match imap_client.folder_status(creds, &source.folder).await { + Ok(status) if status.uid_validity == source.uid_validity => { + if let Err(error) = imap_client + .expunge_message(creds, &source.folder, source.uid) + .await + { + tracing::warn!( + error = %error, + folder = %source.folder, + uid = source.uid, + "Failed to remove superseded IMAP draft" + ); + } + } + Ok(status) => tracing::info!( + folder = %source.folder, + uid = source.uid, + stored_uid_validity = source.uid_validity, + current_uid_validity = status.uid_validity, + "Skipping stale IMAP draft cleanup after UIDVALIDITY change" + ), + Err(error) => tracing::warn!( + error = %error, + folder = %source.folder, + uid = source.uid, + "Could not verify source IMAP draft before cleanup" + ), + } +} + async fn remove_previous_remote_snapshot( imap_client: &dyn ImapClient, creds: &ImapCredentials, @@ -317,6 +654,51 @@ pub(crate) async fn remove_remote_draft_snapshot( } } +async fn delete_imap_draft_source( + imap_client: &dyn ImapClient, + creds: &ImapCredentials, + configured_drafts_folder: &str, + source: &ImapDraftSource, +) -> Result { + let status = imap_client + .folder_status(creds, &source.folder) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + if status.uid_validity != source.uid_validity { + return Err(AppError::BadRequest( + "The draft changed on the IMAP server; reopen it before deleting".to_string(), + )); + } + + let headers = imap_client + .fetch_headers(creds, &source.folder, &source.uid.to_string()) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + let Some(header) = headers.into_iter().find(|item| item.uid == source.uid) else { + // It was already removed by another client. Treat that as a successful + // remote deletion so local recovery data can still be cleaned up. + return Ok(false); + }; + let is_draft = header + .flags + .iter() + .any(|flag| flag.eq_ignore_ascii_case("\\Draft")) + || source.folder.as_str() == configured_drafts_folder; + if !is_draft { + return Err(AppError::BadRequest( + "The IMAP message is no longer a draft; reopen the mailbox before deleting".to_string(), + )); + } + + imap_client + .expunge_message(creds, &source.folder, source.uid) + .await + .map_err(|e| { + AppError::ServiceUnavailable(format!("Failed to delete draft from IMAP: {e}")) + })?; + Ok(true) +} + async fn load_snapshot_attachments( conn: &crate::db::pool::Connection, draft_id: &str, @@ -343,17 +725,69 @@ async fn load_snapshot_attachments( Ok(attachments) } +fn split_mailbox_list(value: &str) -> Vec { + let mut values = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut escaped = false; + let mut angle_depth = 0usize; + for ch in value.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' && quoted { + current.push(ch); + escaped = true; + continue; + } + if ch == '"' { + quoted = !quoted; + current.push(ch); + continue; + } + if !quoted && ch == '<' { + angle_depth += 1; + } else if !quoted && ch == '>' && angle_depth > 0 { + angle_depth -= 1; + } + if ch == ',' && !quoted && angle_depth == 0 { + let token = current.trim(); + if !token.is_empty() { + values.push(token.to_string()); + } + current.clear(); + continue; + } + current.push(ch); + } + let token = current.trim(); + if !token.is_empty() { + values.push(token.to_string()); + } + values +} + fn build_draft_rfc822( req: &UpsertDraftRequest, + from_header: &str, from_email: &str, attachments: &[AttachmentData], ) -> Result, String> { - use lettre::message::{ - Attachment, Mailbox, MultiPart, SinglePart, - header::{ContentType, HeaderName, HeaderValue}, + use lettre::{ + Address, + address::Envelope, + message::{ + Attachment, Mailbox, MultiPart, SinglePart, + header::{ContentType, HeaderName, HeaderValue}, + }, }; - let from_mailbox: Mailbox = from_email + let from_mailbox: Mailbox = from_header + .parse() + .map_err(|e: lettre::address::AddressError| e.to_string())?; + let from_address: Address = from_email .parse() .map_err(|e: lettre::address::AddressError| e.to_string())?; @@ -361,26 +795,41 @@ fn build_draft_rfc822( let mut builder = lettre::Message::builder() .from(from_mailbox) .subject(&req.subject) + .keep_bcc() .raw_header(HeaderValue::new( HeaderName::new_from_ascii_str("X-RustBox-Draft-ID"), draft_id, )); - for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let mut valid_recipients = 0usize; + for addr in split_mailbox_list(&req.to) { if let Ok(mailbox) = addr.parse::() { builder = builder.to(mailbox); + valid_recipients += 1; } } - for addr in req.cc.split(',').map(str::trim).filter(|s| !s.is_empty()) { + for addr in split_mailbox_list(&req.cc) { if let Ok(mailbox) = addr.parse::() { builder = builder.cc(mailbox); + valid_recipients += 1; } } - for addr in req.bcc.split(',').map(str::trim).filter(|s| !s.is_empty()) { + for addr in split_mailbox_list(&req.bcc) { if let Ok(mailbox) = addr.parse::() { builder = builder.bcc(mailbox); + valid_recipients += 1; } } + + // Lettre requires at least one envelope destination even when merely serializing + // an RFC822 message. Drafts, however, are valid before the user chooses any + // recipient. Force a private build-only envelope in that case; it is not part + // of the formatted RFC822 headers and therefore does not alter the saved draft. + if valid_recipients == 0 { + let envelope = Envelope::new(Some(from_address.clone()), vec![from_address]) + .map_err(|e| e.to_string())?; + builder = builder.envelope(envelope); + } if let Some(ref irt) = req.in_reply_to { builder = builder.in_reply_to(irt.clone()); } @@ -425,7 +874,8 @@ fn build_draft_rfc822( att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN); let cid = att.content_id.as_deref().unwrap_or("unknown"); related = related.singlepart( - Attachment::new_inline(cid.to_string()).body(att.data.clone(), content_type), + Attachment::new_inline_with_name(cid.to_string(), att.filename.clone()) + .body(att.data.clone(), content_type), ); } MultiPart::alternative() @@ -461,6 +911,160 @@ fn build_draft_rfc822( Ok(email.formatted()) } +fn unfolded_header_value(raw_headers: &str, name: &str) -> Option { + let wanted = name.trim().to_ascii_lowercase(); + let mut current_name = String::new(); + let mut current_value = String::new(); + let flush = |header_name: &str, value: &str| -> Option { + if header_name.eq_ignore_ascii_case(&wanted) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + None + }; + + for line in raw_headers.lines() { + if line.starts_with(' ') || line.starts_with('\t') { + if !current_value.is_empty() { + current_value.push(' '); + } + current_value.push_str(line.trim()); + continue; + } + if let Some(value) = flush(¤t_name, ¤t_value) { + return Some(value); + } + current_name.clear(); + current_value.clear(); + if let Some((header_name, value)) = line.split_once(':') { + current_name.push_str(header_name.trim()); + current_value.push_str(value.trim()); + } + } + flush(¤t_name, ¤t_value) +} + +fn format_imap_addresses(addresses: &[crate::imap::types::EmailAddress]) -> String { + addresses + .iter() + .filter_map(|entry| { + let address = entry.address.trim(); + if address.is_empty() { + return None; + } + let name = entry.name.as_deref().unwrap_or("").trim(); + if name.is_empty() { + Some(address.to_string()) + } else { + let escaped = name.replace('\\', "\\\\").replace('"', "\\\""); + Some(format!("\"{escaped}\" <{address}>")) + } + }) + .collect::>() + .join(", ") +} + +fn draft_priority(raw_headers: &str) -> String { + let value = unfolded_header_value(raw_headers, "X-Priority") + .or_else(|| unfolded_header_value(raw_headers, "Priority")) + .unwrap_or_default() + .to_ascii_lowercase(); + if value.starts_with('1') || value.starts_with('2') || value.contains("high") { + "high".to_string() + } else if value.starts_with('4') || value.starts_with('5') || value.contains("low") { + "low".to_string() + } else { + "normal".to_string() + } +} + +pub async fn get_imap_draft_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + AxumPath((folder, uid)): AxumPath<(String, u32)>, +) -> Result { + if session.imap_host.trim().is_empty() { + return Err(AppError::ServiceUnavailable( + "IMAP is not available for opening this draft".to_string(), + )); + } + let creds = session_imap_credentials(&session); + let headers = imap_client + .fetch_headers(&creds, &folder, &uid.to_string()) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + let header = headers + .into_iter() + .find(|item| item.uid == uid) + .ok_or_else(|| AppError::NotFound(format!("Draft UID {uid} not found in {folder}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + let configured_drafts = + resolve_drafts_folder(&conn, &config).unwrap_or_else(|_| "Drafts".to_string()); + let is_draft = header + .flags + .iter() + .any(|flag| flag.eq_ignore_ascii_case("\\Draft")) + || folder == configured_drafts; + if !is_draft { + return Err(AppError::BadRequest( + "This IMAP message is not marked as a draft".to_string(), + )); + } + + let status = imap_client + .folder_status(&creds, &folder) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + let body = imap_client + .fetch_body(&creds, &folder, uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + let attachments = imap_attachment_infos(&body.attachments); + + let from_address = header + .from + .first() + .map(|entry| entry.address.clone()) + .unwrap_or_else(|| session.email.clone()); + let request_mdn = unfolded_header_value(&body.raw_headers, "Disposition-Notification-To") + .is_some() + || unfolded_header_value(&body.raw_headers, "Return-Receipt-To").is_some(); + let compose_format = if body.text_html.is_some() { + "html" + } else { + "text" + } + .to_string(); + + Ok(Json(ImapDraftComposeResponse { + to: format_imap_addresses(&header.to), + cc: format_imap_addresses(&header.cc), + bcc: unfolded_header_value(&body.raw_headers, "Bcc").unwrap_or_default(), + subject: header.subject.unwrap_or_default(), + text_body: body.text_plain.unwrap_or_default(), + html_body: body.text_html, + in_reply_to: header.in_reply_to, + references: header.references, + request_mdn, + priority: draft_priority(&body.raw_headers), + compose_format, + from_address, + imap_source: ImapDraftSource { + folder, + uid, + uid_validity: status.uid_validity, + }, + attachments, + }) + .into_response()) +} + pub async fn list_drafts_handler( Extension(session): Extension, Extension(config): Extension>, @@ -541,38 +1145,67 @@ pub async fn delete_draft_handler( Extension(imap_client): Extension>, Extension(storage): Extension>, AxumPath(id): AxumPath, + Query(query): Query, ) -> Result { db::drafts::validate_draft_id(&id).map_err(AppError::BadRequest)?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let existing = db::drafts::get_draft(&conn, &id).map_err(AppError::InternalError)?; - let Some(existing) = existing else { + let explicit_source = query.imap_source()?; + if existing.is_none() && explicit_source.is_none() { return Err(AppError::NotFound("Draft not found".to_string())); - }; - if !session.imap_host.trim().is_empty() { - let creds = session_imap_credentials(&session); - remove_remote_draft_snapshot(imap_client.as_ref(), &creds, &existing).await; } - let attachments = - db::drafts::get_draft_attachments(&conn, &id).map_err(AppError::InternalError)?; - let deleted = db::drafts::delete_draft(&conn, &id).map_err(AppError::InternalError)?; - if !deleted { - return Err(AppError::NotFound("Draft not found".to_string())); + let stored_source = existing.as_ref().and_then(|draft| { + Some(ImapDraftSource { + folder: draft.remote_folder.clone()?, + uid: draft.remote_uid?, + uid_validity: draft.remote_uid_validity?, + }) + }); + let remote_source = explicit_source.or(stored_source); + let mut remote_deleted = false; + if let Some(source) = remote_source.as_ref() { + if session.imap_host.trim().is_empty() { + return Err(AppError::ServiceUnavailable( + "IMAP is not available for deleting this draft".to_string(), + )); + } + let creds = session_imap_credentials(&session); + let drafts_folder = + resolve_drafts_folder(&conn, &config).map_err(AppError::InternalError)?; + remote_deleted = + delete_imap_draft_source(imap_client.as_ref(), &creds, &drafts_folder, source).await?; } + + let attachments = if existing.is_some() { + db::drafts::get_draft_attachments(&conn, &id).map_err(AppError::InternalError)? + } else { + Vec::new() + }; + if existing.is_some() { + let deleted = db::drafts::delete_draft(&conn, &id).map_err(AppError::InternalError)?; + if !deleted { + return Err(AppError::NotFound("Draft not found".to_string())); + } + } + let mut attachments_deleted = 0usize; for attachment in &attachments { - if let Err(error) = storage.delete(&attachment.storage_key).await { - tracing::warn!( + match storage.delete(&attachment.storage_key).await { + Ok(()) => attachments_deleted += 1, + Err(error) => tracing::warn!( error = %error, storage_key = %attachment.storage_key, "Failed to remove deleted draft attachment blob" - ); + ), } } Ok(Json(DeleteResponse { status: "deleted".to_string(), + remote_deleted, + attachments_deleted, }) .into_response()) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b0d7b11..85c3ab7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -309,6 +309,10 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic "/messages/{folder}/{uid}/source", get(messages::download_eml_source), ) + .route( + "/messages/{folder}/{uid}/draft-compose", + get(drafts::get_imap_draft_handler), + ) .route("/messages/move", post(messages::move_message_handler)) .route("/messages/copy", post(messages::copy_message_handler)) .route("/messages/send", post(send::send_message_handler)) diff --git a/src/routes/send.rs b/src/routes/send.rs index 1a05755..192c985 100644 --- a/src/routes/send.rs +++ b/src/routes/send.rs @@ -9,6 +9,7 @@ use crate::config::AppConfig; use crate::db; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::routes::drafts::{ImapDraftAttachmentRef, ImapDraftSource}; use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials}; use crate::smtp::types::{PgpMode, PgpSendConfig}; use crate::storage::{self, BlobStorage}; @@ -48,6 +49,11 @@ pub struct SendRequest { #[serde(default)] pub pgp_attach_recipient_keys: bool, + + pub imap_source: Option, + + #[serde(default)] + pub imap_attachments: Vec, } fn default_priority() -> String { @@ -75,6 +81,8 @@ pub async fn send_message_handler( Extension(storage): Extension>, Json(req): Json, ) -> Result { + let imap_source = req.imap_source.clone(); + let imap_attachment_refs = req.imap_attachments.clone(); if let Some(ref draft_id) = req.draft_id { db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?; } @@ -253,6 +261,23 @@ pub async fn send_message_handler( } else { vec![] }; + if let Some(source) = imap_source.as_ref() { + let mut remote_attachments = crate::routes::drafts::load_imap_source_attachments( + imap_client.as_ref(), + &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(), + }, + source, + &imap_attachment_refs, + ) + .await?; + attachments.append(&mut remote_attachments); + } if let Some((key_id, armor)) = attached_public_key { attachments.push(AttachmentData { filename: format!("openpgp-{key_id}-public.asc"), @@ -356,10 +381,16 @@ pub async fn send_message_handler( } } + let mut source_removed_with_local_draft = false; if let Some(ref draft_id) = req.draft_id { if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) && let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id) { + source_removed_with_local_draft = imap_source.as_ref().is_some_and(|source| { + draft.remote_folder.as_deref() == Some(source.folder.as_str()) + && draft.remote_uid == Some(source.uid) + && draft.remote_uid_validity == Some(source.uid_validity) + }); crate::routes::drafts::remove_remote_draft_snapshot( imap_client.as_ref(), &imap_creds, @@ -375,6 +406,16 @@ pub async fn send_message_handler( ) .await; } + if !source_removed_with_local_draft && let Some(source) = imap_source.as_ref() { + crate::routes::drafts::remove_imap_source_snapshot( + imap_client.as_ref(), + &imap_creds, + source, + "", + 0, + ) + .await; + } Ok(Json(SendResponse { status: "sent".to_string(), diff --git a/src/smtp/builder.rs b/src/smtp/builder.rs index b95bc2f..f92730b 100644 --- a/src/smtp/builder.rs +++ b/src/smtp/builder.rs @@ -185,7 +185,7 @@ fn build_clear_content(message: &SendableMessage) -> Result { att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN); let content_id = att.content_id.as_deref().unwrap_or("unknown"); related = related.singlepart( - Attachment::new_inline(content_id.to_string()) + Attachment::new_inline_with_name(content_id.to_string(), att.filename.clone()) .body(att.data.clone(), content_type), ); } diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index 2ca9640..b7f3dd7 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -241,8 +241,23 @@ height: 12px; } +.message-load-actions { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + padding: 7px; + box-sizing: border-box; +} + +.message-load-actions.hidden { + display: none; +} + .load-more { - margin: 7px; + width: 100%; + min-width: 0; + margin: 0; } .reader-pane { @@ -428,6 +443,11 @@ grid-template-columns: 12px 5px; } +.recipient-more>span { + color: var(--muted); + font-weight: 650; +} + .sender span, .message-meta time { color: var(--muted); diff --git a/src/web/css/40-compose.css b/src/web/css/40-compose.css index 79aacc2..2d2aac8 100644 --- a/src/web/css/40-compose.css +++ b/src/web/css/40-compose.css @@ -52,6 +52,7 @@ display: grid; grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto; overflow: hidden; + position: relative; container-name: compose-card; container-type: inline-size; } @@ -66,6 +67,18 @@ overflow: auto; } +.compose-dialog.compose-fields-collapsed .compose-card { + grid-template-rows: auto auto minmax(240px, 1fr) auto auto auto auto; +} + +.compose-dialog.compose-fields-collapsed .compose-addresses { + display: none; +} + +#compose-fields-toggle { + white-space: nowrap; +} + .compose-addresses>label { display: grid; grid-template-columns: 72px minmax(0, 1fr); @@ -283,17 +296,55 @@ background: var(--field); } +.compose-attachment-kind { + flex: 0 0 auto; + padding: 1px 5px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: var(--panel-2); + font-size: 9px; + font-weight: 700; + line-height: 1.4; +} + +.compose-attachment-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .compose-attachment button { - width: 20px; - height: 20px; - display: grid; - place-items: center; - padding: 0; + min-width: 20px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 4px; border: 0; + border-radius: var(--radius-control); background: transparent; color: var(--muted); } +.compose-attachment button:hover, +.compose-attachment button:focus-visible { + background: var(--hover); + color: var(--text); +} + +.compose-attachment button svg { + width: 13px; + height: 13px; +} + +.compose-attachment-remove { + width: 22px; + padding: 0 !important; +} + .compose-mode-control { flex: 0 0 auto; justify-self: start; @@ -441,6 +492,81 @@ outline-offset: 2px; } +.compose-image-actions { + position: fixed; + z-index: 140; + display: flex; + align-items: center; + flex-wrap: wrap; + width: max-content; + max-width: min(560px, calc(100vw - 16px)); + gap: 3px; + padding: 5px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-panel); + background: var(--panel); + box-shadow: 0 8px 26px var(--shadow); +} + +.compose-image-actions.hidden { + display: none; +} + +.compose-image-resize { + position: fixed; + z-index: 139; + pointer-events: none; + border: 1px solid var(--accent); + box-sizing: border-box; +} + +.compose-image-resize.hidden { + display: none; +} + +.compose-image-resize-handle { + position: absolute; + width: 11px; + height: 11px; + border: 2px solid var(--panel); + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + pointer-events: auto; + touch-action: none; +} + +.compose-image-resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; } +.compose-image-resize-handle.ne { right: -6px; top: -6px; cursor: nesw-resize; } +.compose-image-resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; } +.compose-image-resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; } + +.compose-image-resizing, +.compose-image-resizing * { + user-select: none !important; +} + +.compose-image-actions-label { + max-width: 180px; + margin-right: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 10px; + font-weight: 700; +} + +.compose-image-actions .button.active { + border-color: var(--accent); + background: var(--selected); + color: var(--text); +} + +.compose-image-actions .toolbar-separator { + height: 20px; +} + .image-edit-dialog { width: min(840px, calc(100vw - 28px)); max-width: none; diff --git a/src/web/i18n/en.json b/src/web/i18n/en.json index e884dd8..53df9dc 100644 --- a/src/web/i18n/en.json +++ b/src/web/i18n/en.json @@ -52,7 +52,7 @@ "reply_all": "Reply all", "forward": "Forward", "headers": "Headers", - "message_details": "Message details", + "message_details": "Details", "move_copy": "Move / copy", "move_to": "Move to…", "copy_to": "Copy to…", diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 5ac7b12..3431b1c 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -599,6 +599,12 @@ "Copy address": "Kopiuj adres", "Search messages": "Szukaj wiadomości", "Draft saved": "Szkic zapisany", + "Save draft": "Zapisz szkic", + "Draft saved to IMAP": "Szkic zapisany na IMAP", + "Delete draft": "Usuń szkic", + "Draft deleted": "Szkic usunięty", + "Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.": "Usunąć ten szkic? Szkic z IMAP oraz wszystkie lokalnie przesłane załączniki szkicu zostaną trwale usunięte.", + "Wait for attachment upload to finish": "Poczekaj na zakończenie wysyłania załącznika", "Uploading files…": "Wysyłanie plików…", "Upload complete": "Wysyłanie zakończone", "Allow editing predefined style": "Zezwól na edycję gotowego stylu", @@ -629,7 +635,11 @@ "Discord inspired": "Inspirowany Discord", "Slack inspired": "Inspirowany Slack", "Reddit inspired": "Inspirowany Reddit", - "Notion inspired": "Inspirowany Notion" + "Notion inspired": "Inspirowany Notion", + "Hide fields": "Ukryj pola", + "Show fields": "Pokaż pola", + "Hide addressing fields": "Ukryj pola adresowe", + "Show addressing fields": "Pokaż pola adresowe" }, "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.", diff --git a/src/web/index.html b/src/web/index.html index 28c3a44..836ee1e 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -180,7 +180,10 @@
- + @@ -221,7 +224,7 @@
Message details + Details