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