v0.2.12
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 13:54:18 +01:00
1 parent 5dc9db0b1c
commit 83440f20e2
28 files changed
+1925 -213

No files matched your search

+38
View File
@@ -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.
Generated
+1 -1
View File
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]]
name = "rustbox"
version = "0.2.11"
version = "0.2.12"
dependencies = [
"aes-gcm",
"async-imap",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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`
+3 -3
View File
@@ -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
```
+21 -4
View File
@@ -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
+17 -5
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.11
# RustBox 0.2.12
host = "127.0.0.1"
port = 3001
environment = "production"
+16 -5
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+34 -7
View File
@@ -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('<meta charset="utf-8">${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")
+703 -70
View File
@@ -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<i64>,
#[serde(default)]
pub to: String,
#[serde(default)]
@@ -46,6 +61,10 @@ pub struct UpsertDraftRequest {
#[serde(default)]
pub sync_imap: bool,
pub imap_source: Option<ImapDraftSource>,
#[serde(default)]
pub imap_attachments: Vec<ImapDraftAttachmentRef>,
}
fn default_priority() -> String {
@@ -63,6 +82,78 @@ fn default_pgp_mode() -> String {
struct DraftResponse {
id: String,
status: String,
remote_folder: Option<String>,
remote_uid: Option<u32>,
remote_uid_validity: Option<u32>,
remote_attachments: Vec<ImapDraftAttachmentInfo>,
}
#[derive(Debug, Serialize)]
struct ImapDraftAttachmentInfo {
id: String,
source_index: usize,
source_content_id: Option<String>,
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<String>,
in_reply_to: Option<String>,
references: Option<String>,
request_mdn: bool,
priority: String,
compose_format: String,
from_address: String,
imap_source: ImapDraftSource,
attachments: Vec<ImapDraftAttachmentInfo>,
}
fn imap_attachment_infos(
attachments: &[crate::imap::types::ImapAttachment],
) -> Vec<ImapDraftAttachmentInfo> {
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<String>,
pub imap_uid: Option<u32>,
pub imap_uid_validity: Option<u32>,
}
impl DeleteDraftQuery {
fn imap_source(&self) -> Result<Option<ImapDraftSource>, 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<String, String> {
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<Vec<AttachmentData>, 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<bool, AppError> {
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<String> {
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<Vec<u8>, 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::<Mailbox>() {
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::<Mailbox>() {
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::<Mailbox>() {
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<String> {
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<String> {
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(&current_name, &current_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(&current_name, &current_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::<Vec<_>>()
.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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
AxumPath((folder, uid)): AxumPath<(String, u32)>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -541,38 +1145,67 @@ pub async fn delete_draft_handler(
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(storage): Extension<Arc<dyn BlobStorage>>,
AxumPath(id): AxumPath<String>,
Query(query): Query<DeleteDraftQuery>,
) -> Result<Response, AppError> {
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())
}
+4
View File
@@ -309,6 +309,10 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, 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))
+41
View File
@@ -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<ImapDraftSource>,
#[serde(default)]
pub imap_attachments: Vec<ImapDraftAttachmentRef>,
}
fn default_priority() -> String {
@@ -75,6 +81,8 @@ pub async fn send_message_handler(
Extension(storage): Extension<Arc<dyn BlobStorage>>,
Json(req): Json<SendRequest>,
) -> Result<Response, AppError> {
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(),
+1 -1
View File
@@ -185,7 +185,7 @@ fn build_clear_content(message: &SendableMessage) -> Result<MultiPart, String> {
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),
);
}
+21 -1
View File
@@ -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);
+131 -5
View File
@@ -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;
+1 -1
View File
@@ -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…",
+11 -1
View File
@@ -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.",
+31 -4
View File
@@ -180,7 +180,10 @@
<button id="selection-done" class="button quiet" type="button">Done</button>
</div>
<div id="message-list" class="message-list" aria-live="polite"></div>
<button id="load-more" class="button quiet load-more hidden" type="button">Load more</button>
<div id="message-load-actions" class="message-load-actions hidden">
<button id="load-more" class="button quiet load-more" type="button">Load more</button>
<button id="load-all" class="button quiet load-more" type="button">Load all</button>
</div>
<div id="message-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
aria-label="Resize message list"></div>
</section>
@@ -221,7 +224,7 @@
<details id="message-details-menu" class="toolbar-menu">
<summary class="button quiet"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg><span data-i18n="message_details">Message details</span></summary>
</svg><span data-i18n="message_details">Details</span></summary>
<div class="toolbar-menu-popover" role="menu">
<button id="headers-btn" type="button" role="menuitem" data-i18n="headers">Headers</button>
<button id="source-btn" type="button" role="menuitem" data-i18n="source">Source</button>
@@ -307,7 +310,8 @@
<dialog id="compose-dialog" class="dialog compose-dialog" data-i18n-scope>
<form id="compose-form" class="dialog-card compose-card" method="dialog">
<header class="dialog-header"><strong id="compose-title">New message</strong>
<div class="compose-header-actions"><button type="button" id="compose-recover" class="button quiet small"
<div class="compose-header-actions"><button type="button" id="compose-fields-toggle" class="button quiet small"
aria-expanded="true" title="Hide addressing fields">Hide fields</button><button type="button" id="compose-recover" class="button quiet small"
title="Recover a saved draft">Recover draft</button><button type="button" id="compose-fullscreen"
class="icon-button compose-fullscreen-toggle" aria-label="Toggle full screen composer" aria-pressed="false"
title="Full screen">⛶</button><button type="button" class="icon-button close-dialog"
@@ -440,6 +444,27 @@
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg></button>
</div>
<div id="compose-image-actions" class="compose-image-actions hidden" role="toolbar" aria-label="Inline image actions">
<span class="compose-image-actions-label">Inline image</span>
<button type="button" class="button quiet small" data-image-width="25">25%</button>
<button type="button" class="button quiet small" data-image-width="50">50%</button>
<button type="button" class="button quiet small" data-image-width="75">75%</button>
<button type="button" class="button quiet small" data-image-width="100">100%</button>
<span class="toolbar-separator"></span>
<button type="button" class="button quiet small" data-image-align="inline">Inline</button>
<button type="button" class="button quiet small" data-image-align="left">Left</button>
<button type="button" class="button quiet small" data-image-align="center">Center</button>
<button type="button" class="button quiet small" data-image-align="right">Right</button>
<span class="toolbar-separator"></span>
<button type="button" id="compose-image-more" class="button quiet small">More…</button>
<button type="button" id="compose-image-detach" class="button quiet small">Keep as attachment</button>
</div>
<div id="compose-image-resize" class="compose-image-resize hidden" aria-hidden="true">
<span class="compose-image-resize-handle nw" data-image-resize="nw"></span>
<span class="compose-image-resize-handle ne" data-image-resize="ne"></span>
<span class="compose-image-resize-handle sw" data-image-resize="sw"></span>
<span class="compose-image-resize-handle se" data-image-resize="se"></span>
</div>
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"
data-placeholder="Write a message"></div>
<div id="compose-upload-progress" class="compose-upload-progress hidden" role="status" aria-live="polite">
@@ -457,7 +482,9 @@
<span id="compose-status" class="muted"></span><span id="compose-message-size"
class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__
MiB</span>
<div class="compose-submit-actions"><button type="button"
<div class="compose-submit-actions"><button id="compose-delete-draft" type="button"
class="button quiet danger hidden">Delete draft</button><button id="compose-save-draft" type="button"
class="button quiet">Save draft</button><button type="button"
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit"
class="button primary"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
+1
View File
@@ -75,6 +75,7 @@
imapSyncInventoryPending: 0,
calendarReminderTimer: null,
composeDraftId: null,
composeRemoteDraft: null,
composeAttachments: [],
composeDirty: false,
composeNeedsRemoteSync: false,
+88 -24
View File
@@ -65,6 +65,31 @@ async function loadMessages(reset = false, options = {}) {
}
}
async function loadAllMessages() {
if (state.searchContext || state.messages.length >= state.total) return;
const loadAll = $("#load-all");
const loadMore = $("#load-more");
setBusy(loadAll, true);
if (loadMore) loadMore.disabled = true;
try {
let guard = 0;
while (state.messages.length < state.total && guard < 10000) {
const before = state.messages.length;
state.page += 1;
await loadMessages(false, { background: true });
guard += 1;
if (state.messages.length <= before) {
state.page = Math.max(0, state.page - 1);
break;
}
}
saveMessageListState();
} finally {
setBusy(loadAll, false);
if (loadMore) loadMore.disabled = false;
}
}
function normalizedMessageFlags(value) {
const source = Array.isArray(value) ? value : String(value || "").split(/[,\s]+/).filter(Boolean);
return [...new Set(source.map((flag) => String(flag).trim()).filter(Boolean))].sort();
@@ -276,7 +301,8 @@ function renderMessages() {
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
list.append(row);
}
$("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total);
const canLoadMore = !state.searchContext && state.messages.length < state.total;
$("#message-load-actions")?.classList.toggle("hidden", !canLoadMore);
renderBulkToolbar();
}
@@ -387,7 +413,26 @@ async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
}
function messageLooksLikeImapDraft(folder, uid) {
if (state.specialFolders?.drafts && state.specialFolders.drafts === folder) return true;
const folderMeta = state.folders.find((item) => item.name === folder);
const attrs = Array.isArray(folderMeta?.attributes) ? folderMeta.attributes : String(folderMeta?.attributes || "").split(/\s+/);
if (attrs.some((value) => String(value).toLowerCase() === "\\drafts")) return true;
const message = state.messages.find((item) => item.folder === folder && Number(item.uid) === Number(uid))
|| state.messages.find((item) => Number(item.uid) === Number(uid));
const flags = Array.isArray(message?.flags) ? message.flags : String(message?.flags || "").split(",");
return flags.some((value) => String(value).trim().toLowerCase() === "\\draft");
}
async function openMessage(folder, uid) {
if (messageLooksLikeImapDraft(folder, uid)) {
try {
await openImapDraftComposer(folder, uid);
return;
} catch (error) {
toast(error.message || "Could not open IMAP draft", "error");
}
}
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
@@ -460,40 +505,59 @@ function addressLabel(entry) {
return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
}
function readerRecipientDisplayLimit(host, total) {
if (total <= 3) return total;
const width = host?.closest(".sender")?.getBoundingClientRect().width || host?.clientWidth || 0;
if (width >= 560) return 3;
if (width >= 320) return 2;
return 1;
}
function appendReaderRecipientRow(host, prefix, entry) {
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
}
function renderReaderRecipients(detail) {
const host = $("#reader-to");
host.replaceChildren();
const groups = [
["to", detail.to_addresses || []],
["cc", detail.cc_addresses || []],
];
let added = 0;
for (const [prefix, entries] of groups) {
for (const entry of entries) {
if (!entry?.address) continue;
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
added += 1;
}
const recipients = [];
for (const [prefix, entries] of [["to", detail.to_addresses || []], ["cc", detail.cc_addresses || []]]) {
for (const entry of entries) if (entry?.address) recipients.push({ prefix, entry });
}
if (!recipients.length) {
host.append(el("div", "recipient-line", "to me"));
return;
}
const expanded = host.getAttribute("aria-expanded") === "true";
const limit = expanded ? recipients.length : readerRecipientDisplayLimit(host, recipients.length);
recipients.slice(0, limit).forEach(({ prefix, entry }) => appendReaderRecipientRow(host, prefix, entry));
const hidden = recipients.length - limit;
if (hidden > 0) {
const more = el("div", "recipient-line recipient-more");
more.append(el("span", "", `+${hidden}`));
host.append(more);
host.title = tr("show_full_addresses", "Show full addresses");
}
if (!added) host.append(el("div", "recipient-line", "to me"));
}
function setReaderAddressExpansion(expanded) {
function setReaderAddressExpansion(expanded, rerender = true) {
const host = $("#reader-to");
const meta = host?.closest(".message-meta");
if (!host || !meta) return;
meta.classList.toggle("addresses-expanded", Boolean(expanded));
host.setAttribute("aria-expanded", String(Boolean(expanded)));
host.title = expanded ? tr("collapse_addresses", "Collapse addresses") : tr("show_full_addresses", "Show full addresses");
if (rerender && state.currentDetail) renderReaderRecipients(state.currentDetail);
}
function toggleReaderAddressExpansion() {
@@ -508,7 +572,7 @@ function renderMessageDetail(detail) {
$("#reader-from").tabIndex = detail.from_address ? 0 : -1;
if (detail.from_address) $("#reader-from").setAttribute("role", "button");
else $("#reader-from").removeAttribute("role");
setReaderAddressExpansion(false);
setReaderAddressExpansion(false, false);
renderReaderRecipients(detail);
$("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address);
$("#reader-date").textContent = formatDate(detail.date, true);
+667 -65
View File
@@ -1,6 +1,25 @@
function parseRecipients(raw) {
return raw.split(",").map((value) => value.trim()).filter(Boolean);
const values = [];
let current = "";
let quoted = false;
let escaped = false;
let angleDepth = 0;
for (const char of String(raw || "")) {
if (escaped) { current += char; escaped = false; continue; }
if (char === "\\" && quoted) { current += char; escaped = true; continue; }
if (char === '"') { quoted = !quoted; current += char; continue; }
if (!quoted && char === "<") angleDepth += 1;
if (!quoted && char === ">" && angleDepth > 0) angleDepth -= 1;
if (char === "," && !quoted && angleDepth === 0) {
if (current.trim()) values.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) values.push(current.trim());
return values;
}
function recipientToken(input) {
@@ -137,20 +156,86 @@ function updateComposeMessageSize(extraFiles = []) {
return estimated;
}
function mergeComposeAttachments(...lists) {
const merged = new Map();
for (const list of lists) {
for (const attachment of Array.isArray(list) ? list : []) {
const id = String(attachment?.id || "");
if (!id) continue;
merged.set(id, { ...(merged.get(id) || {}), ...attachment, id: attachment.id || id });
}
}
return [...merged.values()];
}
async function refreshComposeAttachmentsFromServer(draftId = state.composeDraftId) {
if (!draftId) return state.composeAttachments;
try {
const draft = await request(`/drafts/${encodeURIComponent(draftId)}`, { method: "GET" });
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, draft.attachments);
renderComposeAttachments();
} catch (_) {
// Keep the local list if the draft endpoint is temporarily unavailable.
}
return state.composeAttachments;
}
function detachComposeAttachmentFromBody(id) {
const cid = String(id || "");
if (!cid) return false;
let changed = false;
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid || "") !== cid) return;
if (node === state.composeSelectedImage) setSelectedInlineImage(null);
node.remove();
changed = true;
});
if (!changed) return false;
syncComposeInlineAttachments();
scheduleDraftSave();
return true;
}
function renderComposeAttachments() {
const box = $("#compose-attachments");
box.replaceChildren();
const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)));
box.classList.toggle("hidden", !visibleAttachments.length);
for (const attachment of visibleAttachments) {
const attachments = mergeComposeAttachments(state.composeAttachments);
state.composeAttachments = attachments;
box.classList.toggle("hidden", !attachments.length);
for (const attachment of attachments) {
const id = String(attachment.id || "");
const filename = attachment.filename || "attachment";
const isImage = composeAttachmentIsImage(attachment);
const isInline = state.inlineAttachmentIds.has(id);
const item = el("div", "compose-attachment");
item.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
const remove = el("button", "");
item.dataset.attachmentId = id;
const kind = isInline ? (isImage ? "Inline image" : "Inline link") : (isImage ? "Image attachment" : "Attachment");
item.innerHTML = `${svgIcon("paperclip")}<span class="compose-attachment-kind">${kind}</span><span class="compose-attachment-label">${escapeHtml(filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
const embed = el("button", "compose-attachment-action");
embed.type = "button";
if (isInline) {
embed.title = `Keep ${filename} as attachment only`;
embed.setAttribute("aria-label", embed.title);
embed.innerHTML = svgIcon("paperclip");
embed.addEventListener("click", () => detachComposeAttachmentFromBody(id));
} else {
embed.title = isImage ? `Embed ${filename} in message` : `Insert a link to ${filename} in message`;
embed.setAttribute("aria-label", embed.title);
embed.innerHTML = svgIcon(isImage ? "image" : "link");
embed.addEventListener("mousedown", () => { state.composeInsertRange = captureEditorRange(); });
embed.addEventListener("click", () => {
insertComposeAttachment(attachment, state.composeInsertRange);
state.composeInsertRange = null;
});
}
const remove = el("button", "compose-attachment-remove");
remove.innerHTML = svgIcon("x");
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${attachment.filename}`);
remove.setAttribute("aria-label", `Remove ${filename}`);
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
item.append(remove);
item.append(embed, remove);
box.append(item);
}
if ($("#compose-pgp-mode")) updateComposePgpUi();
@@ -173,6 +258,120 @@ function composeAttachmentContentUrl(draftId, attachmentId) {
return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`;
}
function composeImapAttachmentContentUrl(attachment, source = state.composeRemoteDraft) {
if (!source || attachment?.source !== "imap") return "";
const index = Number(attachment.source_index);
if (!Number.isInteger(index) || index < 0) return "";
const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
return `${apiPath(`/messages/${encodeURIComponent(source.folder)}/${source.uid}/attachments/${index}`)}${query}`;
}
function composeAttachmentUrl(attachment, draftId = state.composeDraftId) {
if (attachment?.source === "imap") return composeImapAttachmentContentUrl(attachment);
return draftId ? composeAttachmentContentUrl(draftId, attachment.id) : "";
}
function composeAttachmentIsImage(attachment) {
const type = String(attachment?.content_type || attachment?.contentType || "").toLowerCase();
const filename = String(attachment?.filename || "").toLowerCase();
return /^image\/(png|jpeg|gif|webp)$/.test(type) || /\.(png|jpe?g|gif|webp)$/.test(filename);
}
function composeInlineAttachmentIds(editor = $("#compose-editor")) {
if (!editor) return new Set();
return new Set(
[...editor.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]")]
.map((node) => String(node.dataset.rustboxCid || ""))
.filter(Boolean),
);
}
function syncComposeInlineAttachments() {
const next = composeInlineAttachmentIds();
const changed = next.size !== state.inlineAttachmentIds.size || [...next].some((id) => !state.inlineAttachmentIds.has(id));
state.inlineAttachmentIds = next;
if (changed) renderComposeAttachments();
}
function composeAttachmentInsertionRange(range, editor = $("#compose-editor")) {
const target = range?.cloneRange?.() || document.createRange();
try {
if (range) {
const container = target.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? target.commonAncestorContainer
: target.commonAncestorContainer.parentElement;
if (container !== editor && !editor.contains(container)) throw new Error("stale compose range");
// Never replace selected text or a previously selected inline image when adding another file.
target.collapse(false);
} else {
target.selectNodeContents(editor);
target.collapse(false);
}
} catch (_) {
target.selectNodeContents(editor);
target.collapse(false);
}
return target;
}
function insertComposeAttachment(attachment, insertionRange = null) {
if (!attachment?.id) return false;
if ($("#compose-mode")?.value !== "html") {
toast("Switch to HTML mode to embed attachments in the message", "warning");
return false;
}
const editor = $("#compose-editor");
const id = String(attachment.id);
const filename = attachment.filename || "attachment";
const contentUrl = composeAttachmentUrl(attachment, ensureDraftId());
if (!contentUrl) return false;
let inserted;
try {
editor.focus();
const range = composeAttachmentInsertionRange(insertionRange, editor);
if (composeAttachmentIsImage(attachment)) {
inserted = document.createElement("img");
inserted.src = contentUrl;
inserted.dataset.rustboxCid = id;
inserted.dataset.rustboxAlign = "inline";
inserted.alt = filename;
inserted.draggable = true;
inserted.style.maxWidth = "100%";
inserted.style.height = "auto";
} else {
inserted = document.createElement("a");
inserted.href = contentUrl;
inserted.dataset.rustboxCid = id;
inserted.dataset.rustboxAttachmentLink = "true";
inserted.textContent = filename;
}
range.insertNode(inserted);
const spacer = document.createTextNode("\u00a0");
inserted.after(spacer);
const after = document.createRange();
after.setStartAfter(spacer);
after.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(after);
state.inlineAttachmentIds.add(id);
if (composeAttachmentIsImage(attachment)) setSelectedInlineImage(inserted);
else setSelectedInlineImage(null);
} catch (error) {
state.inlineAttachmentIds.delete(id);
renderComposeAttachments();
toast(error?.message || `Could not embed ${filename}`, "error");
return false;
}
syncComposeInlineAttachments();
scheduleDraftSave();
return true;
}
function markdownInlineToSafeHtml(value) {
const source = String(value ?? "");
const code = [];
@@ -277,9 +476,16 @@ function serializeComposeHtml(editor = $("#compose-editor")) {
if (cid) image.setAttribute("src", `cid:${cid}`);
image.removeAttribute("data-rustbox-cid");
image.removeAttribute("data-rustbox-align");
image.removeAttribute("draggable");
image.classList.remove("compose-inline-image-selected");
if (!image.className) image.removeAttribute("class");
});
clone.querySelectorAll("a[data-rustbox-cid]").forEach((link) => {
const cid = link.dataset.rustboxCid || "";
if (cid) link.setAttribute("href", `cid:${cid}`);
link.removeAttribute("data-rustbox-cid");
link.removeAttribute("data-rustbox-attachment-link");
});
const html = clone.innerHTML.trim();
return html ? wrapComposeHtmlFont(html) : null;
}
@@ -290,24 +496,51 @@ function hydrateComposeHtml(html, attachments = state.composeAttachments, draftI
if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) {
host.innerHTML = host.firstElementChild.innerHTML;
}
if (!draftId) return host.innerHTML;
const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean));
const attachmentList = Array.isArray(attachments) ? attachments : [];
const byCid = new Map();
for (const attachment of attachmentList) {
const id = String(attachment?.id || "");
if (!id) continue;
byCid.set(id, attachment);
const sourceCid = String(attachment?.source_content_id || "");
if (sourceCid) byCid.set(sourceCid, attachment);
}
host.querySelectorAll("img[src]").forEach((image) => {
const src = image.getAttribute("src") || "";
if (!src.toLowerCase().startsWith("cid:")) return;
const cid = src.slice(4).replace(/^<|>$/g, "");
if (!ids.has(cid)) return;
state.inlineAttachmentIds.add(cid);
image.dataset.rustboxCid = cid;
image.setAttribute("src", composeAttachmentContentUrl(draftId, cid));
const attachment = byCid.get(cid);
if (!attachment) return;
const id = String(attachment.id);
const contentUrl = composeAttachmentUrl(attachment, draftId);
if (!contentUrl) return;
state.inlineAttachmentIds.add(id);
image.dataset.rustboxCid = id;
image.draggable = true;
image.setAttribute("src", contentUrl);
});
host.querySelectorAll("a[href]").forEach((link) => {
const href = link.getAttribute("href") || "";
if (!href.toLowerCase().startsWith("cid:")) return;
const cid = href.slice(4).replace(/^<|>$/g, "");
const attachment = byCid.get(cid);
if (!attachment) return;
const id = String(attachment.id);
const contentUrl = composeAttachmentUrl(attachment, draftId);
if (!contentUrl) return;
state.inlineAttachmentIds.add(id);
link.dataset.rustboxCid = id;
link.dataset.rustboxAttachmentLink = "true";
link.setAttribute("href", contentUrl);
});
return host.innerHTML;
}
function composeDraftPayload() {
const editor = $("#compose-editor");
return {
const payload = {
id: ensureDraftId(),
from_identity_id: $("#compose-from")?.value ? Number($("#compose-from").value) : null,
to: $("#compose-to").value,
cc: $("#compose-cc").value,
bcc: $("#compose-bcc").value,
@@ -324,6 +557,14 @@ function composeDraftPayload() {
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked),
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked),
};
if (state.composeRemoteDraft) {
payload.imap_source = { ...state.composeRemoteDraft };
payload.imap_attachments = state.composeAttachments
.filter((attachment) => attachment?.source === "imap")
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0);
}
return payload;
}
function readLocalDraftSnapshots() {
@@ -363,24 +604,159 @@ function clearLocalDraftSnapshot(draftId = state.composeDraftId) {
writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId));
}
async function refreshRemoteDraftAttachmentSources(folder, uid, uidValidity, freshAttachments = null) {
const remoteAttachments = state.composeAttachments.filter((attachment) => attachment?.source === "imap");
state.composeRemoteDraft = { folder, uid: Number(uid), uid_validity: Number(uidValidity) };
if (!remoteAttachments.length) return;
try {
let candidates = Array.isArray(freshAttachments) ? freshAttachments : null;
if (!candidates?.length) {
const fresh = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
candidates = Array.isArray(fresh.attachments) ? fresh.attachments : [];
}
const used = new Set();
for (const attachment of remoteAttachments) {
let index = candidates.findIndex((candidate, candidateIndex) =>
!used.has(candidateIndex) && String(candidate.source_content_id || "") === String(attachment.id || ""),
);
if (index < 0) {
index = candidates.findIndex((candidate, candidateIndex) =>
!used.has(candidateIndex)
&& candidate.checksum_sha256 === attachment.checksum_sha256
&& candidate.filename === attachment.filename
&& Number(candidate.size) === Number(attachment.size),
);
}
if (index < 0) continue;
used.add(index);
const candidate = candidates[index];
attachment.source_index = candidate.source_index;
attachment.source_content_id = candidate.source_content_id;
attachment.checksum_sha256 = candidate.checksum_sha256;
}
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
const id = String(node.dataset.rustboxCid || "");
const attachment = state.composeAttachments.find((item) => String(item.id || "") === id && item.source === "imap");
if (!attachment) return;
const url = composeAttachmentUrl(attachment);
if (!url) return;
if (node.tagName === "IMG") node.setAttribute("src", url);
else node.setAttribute("href", url);
});
} catch (error) {
console.warn("Could not refresh IMAP draft attachment sources", error);
}
}
async function saveCurrentDraft(silent = false, force = false, syncImap = false) {
if (!force && !$("#compose-dialog").open) return false;
if (!force && !state.composeDirty) return true;
if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; }
const payload = composeDraftPayload();
payload.sync_imap = Boolean(syncImap);
const draftsFolder = syncImap
? (state.composeRemoteDraft?.folder || state.specialFolders?.drafts || (typeof detectSpecialFolder === "function" ? detectSpecialFolder("drafts") : null))
: null;
const releaseRealtimeSuppression = syncImap && draftsFolder && typeof suppressRealtimeNotificationsForFolder === "function"
? suppressRealtimeNotificationsForFolder(draftsFolder)
: () => { };
try {
await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
const result = await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
state.composeDirty = false;
if (syncImap) state.composeNeedsRemoteSync = false;
clearLocalDraftSnapshot();
$("#compose-status").textContent = `${trText("Draft saved")} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
if (!silent) toast("Draft saved", "success");
if (syncImap && result?.remote_folder && result?.remote_uid && result?.remote_uid_validity) {
await refreshRemoteDraftAttachmentSources(result.remote_folder, result.remote_uid, result.remote_uid_validity, result.remote_attachments);
}
if (!syncImap && state.composeRemoteDraft) storeLocalDraftSnapshot();
else clearLocalDraftSnapshot();
updateComposeDraftDeleteAction();
const savedLabel = syncImap ? "Draft saved to IMAP" : "Draft saved";
$("#compose-status").textContent = `${trText(savedLabel)} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
if (!silent) toast(savedLabel, "success");
return true;
} catch (error) {
storeLocalDraftSnapshot();
if (!silent) toast(error.message, "error");
return false;
} finally {
releaseRealtimeSuppression();
}
}
function updateComposeDraftDeleteAction() {
const button = $("#compose-delete-draft");
if (!button) return;
const canDelete = Boolean(state.composeDraftId || state.composeRemoteDraft);
button.classList.toggle("hidden", !canDelete);
button.disabled = Boolean(state.composeUploading);
}
function composeDraftDeleteUrl() {
if (!state.composeDraftId) return null;
const base = `/drafts/${encodeURIComponent(state.composeDraftId)}`;
const source = state.composeRemoteDraft;
if (!source) return base;
const params = new URLSearchParams({
imap_folder: String(source.folder || ""),
imap_uid: String(Number(source.uid) || 0),
imap_uid_validity: String(Number(source.uid_validity) || 0),
});
return `${base}?${params.toString()}`;
}
async function deleteComposeDraft() {
if (state.composeUploadController || state.composeUploading) {
toast("Wait for attachment upload to finish", "warning");
return;
}
const deleteUrl = composeDraftDeleteUrl();
if (!deleteUrl) return;
const confirmed = await appConfirm(
"Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.",
{ title: "Delete draft", confirmLabel: "Delete draft", danger: true },
);
if (!confirmed) return;
const button = $("#compose-delete-draft");
clearTimeout(state.draftSaveTimer);
clearTimeout(state.inlineImageCleanupTimer);
setBusy(button, true);
try {
await request(deleteUrl, { method: "DELETE" });
const deletedDraftId = state.composeDraftId;
const deletedRemoteDraft = state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null;
clearLocalDraftSnapshot(deletedDraftId);
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = null;
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.pgpRecipientStatus = [];
$("#compose-pgp-passphrase").value = "";
renderComposeAttachments();
updateComposeMessageSize();
updateComposeDraftDeleteAction();
if ($("#compose-dialog").open) $("#compose-dialog").close();
if (deletedRemoteDraft) {
const before = state.messages.length;
state.messages = state.messages.filter((message) => !(
String(message.folder || "") === String(deletedRemoteDraft.folder || "")
&& Number(message.uid) === Number(deletedRemoteDraft.uid)
));
if (state.messages.length !== before) {
state.total = Math.max(0, Number(state.total || 0) - 1);
renderMessages();
}
}
toast("Draft deleted", "success");
await loadFolders();
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
@@ -392,6 +768,22 @@ function scheduleDraftSave() {
state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000)));
}
async function saveComposeDraftToImap() {
const button = $("#compose-save-draft");
if (state.composeUploadController) {
toast("Wait for attachment upload to finish", "warning");
return;
}
clearTimeout(state.draftSaveTimer);
await cleanupUnusedInlineAttachments();
setBusy(button, true);
try {
await saveCurrentDraft(false, true, true);
} finally {
setBusy(button, false);
}
}
function setComposeUploadProgress(visible, percent = 0, label = trText("Uploading…")) {
const box = $("#compose-upload-progress");
const bar = $("#compose-upload-bar");
@@ -407,14 +799,8 @@ async function uploadComposeFiles(files, options = {}) {
const incoming = [...(files || [])];
if (!incoming.length) return [];
if (state.composeUploading) return [];
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
const selected = new Set();
const list = incoming.filter((file) => {
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
if (existing.has(key) || selected.has(key)) return false;
selected.add(key);
return true;
});
// Treat every picker/drop entry as a separate attachment. Files may legitimately share a name and size.
const list = incoming;
if (!list.length) return [];
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
@@ -447,14 +833,16 @@ async function uploadComposeFiles(files, options = {}) {
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
}, controller.signal);
const uploaded = data.attachments || [];
const uploaded = Array.isArray(data.attachments) ? data.attachments : [];
added.push(...uploaded);
state.composeAttachments.push(...uploaded);
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, uploaded);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
}
await refreshComposeAttachmentsFromServer(id);
storeLocalDraftSnapshot();
setComposeUploadProgress(true, 100, trText("Upload complete"));
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
return added;
@@ -489,6 +877,7 @@ function setComposeMode(mode, persist = false) {
$$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; });
const imageEdit = $("#editor-image-edit");
if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage;
updateInlineImageActions();
$("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown");
updateComposeMessageSize();
if (persist) savePreference("composeFormat", value);
@@ -632,14 +1021,151 @@ function captureEditorRange(editor = $("#compose-editor")) {
function restoreEditorRange(range, editor = $("#compose-editor")) {
editor.focus();
if (!range) return;
try {
const selection = window.getSelection();
const target = range?.cloneRange?.() || document.createRange();
if (!range) {
target.selectNodeContents(editor);
target.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
selection.addRange(target);
} catch (_) { }
}
function positionInlineImageActions() {
const image = state.composeSelectedImage;
const actions = $("#compose-image-actions");
const editor = $("#compose-editor");
if (!image?.isConnected || !actions || actions.classList.contains("hidden") || !editor) return;
const imageRect = image.getBoundingClientRect();
const editorRect = editor.getBoundingClientRect();
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom) {
actions.style.visibility = "hidden";
return;
}
actions.style.visibility = "visible";
const actionsRect = actions.getBoundingClientRect();
const gap = 8;
const viewportWidth = window.visualViewport?.width || window.innerWidth;
const viewportHeight = window.visualViewport?.height || window.innerHeight;
let left = imageRect.left;
left = Math.max(gap, Math.min(left, viewportWidth - actionsRect.width - gap));
let top = imageRect.top - actionsRect.height - gap;
if (top < Math.max(gap, editorRect.top + gap)) top = imageRect.bottom + gap;
top = Math.max(gap, Math.min(top, viewportHeight - actionsRect.height - gap));
actions.style.left = `${Math.round(left)}px`;
actions.style.top = `${Math.round(top)}px`;
}
function positionInlineImageResizeHandles() {
const image = state.composeSelectedImage;
const overlay = $("#compose-image-resize");
const editor = $("#compose-editor");
if (!overlay || !editor || !image?.isConnected || $("#compose-mode")?.value !== "html") {
overlay?.classList.add("hidden");
return;
}
const imageRect = image.getBoundingClientRect();
const editorRect = editor.getBoundingClientRect();
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom || imageRect.right < editorRect.left || imageRect.left > editorRect.right) {
overlay.classList.add("hidden");
return;
}
overlay.classList.remove("hidden");
overlay.style.left = `${Math.round(imageRect.left)}px`;
overlay.style.top = `${Math.round(imageRect.top)}px`;
overlay.style.width = `${Math.max(1, Math.round(imageRect.width))}px`;
overlay.style.height = `${Math.max(1, Math.round(imageRect.height))}px`;
}
function positionInlineImageUi() {
positionInlineImageActions();
positionInlineImageResizeHandles();
}
function beginInlineImageResize(event) {
const handle = event.target.closest?.("[data-image-resize]");
const image = state.composeSelectedImage;
const editor = $("#compose-editor");
if (!handle || !image?.isConnected || !editor) return;
event.preventDefault();
event.stopPropagation();
const direction = handle.dataset.imageResize || "se";
const sign = direction.includes("w") ? -1 : 1;
const startX = event.clientX;
const startWidth = image.getBoundingClientRect().width;
const editorWidth = Math.max(1, editor.clientWidth);
const alignment = inlineImageAlignment(image);
document.body.classList.add("compose-image-resizing");
const move = (moveEvent) => {
const delta = (moveEvent.clientX - startX) * sign;
const widthPx = Math.max(editorWidth * 0.10, Math.min(editorWidth, startWidth + delta));
const widthPercent = Math.max(10, Math.min(100, (widthPx / editorWidth) * 100));
applyInlineImageDisplay(image, Math.round(widthPercent * 10) / 10, alignment);
positionInlineImageUi();
};
const finish = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
document.body.classList.remove("compose-image-resizing");
scheduleDraftSave();
positionInlineImageUi();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish, { once: true });
window.addEventListener("pointercancel", finish, { once: true });
}
function moveInlineImageToRange(image, insertionRange) {
const editor = $("#compose-editor");
if (!image?.isConnected || !editor || !insertionRange) return false;
try {
const range = composeAttachmentInsertionRange(insertionRange, editor);
range.insertNode(image);
const spacer = document.createTextNode("\u00a0");
image.after(spacer);
const after = document.createRange();
after.setStartAfter(spacer);
after.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(after);
setSelectedInlineImage(image);
scheduleDraftSave();
requestAnimationFrame(positionInlineImageUi);
return true;
} catch (_) {
return false;
}
}
function updateInlineImageActions() {
const image = state.composeSelectedImage;
const actions = $("#compose-image-actions");
const visible = Boolean(image?.isConnected) && $("#compose-mode")?.value === "html";
actions?.classList.toggle("hidden", !visible);
const resize = $("#compose-image-resize");
resize?.classList.toggle("hidden", !visible);
if (!visible) return;
const label = actions?.querySelector(".compose-image-actions-label");
if (label) {
const attachment = state.composeAttachments.find((item) => String(item.id) === String(image.dataset.rustboxCid || ""));
label.textContent = attachment?.filename ? `Inline image · ${attachment.filename}` : "Inline image";
label.title = label.textContent;
}
const width = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
const alignment = inlineImageAlignment(image);
actions.querySelectorAll("[data-image-width]").forEach((button) => button.classList.toggle("active", Number(button.dataset.imageWidth) === Math.round(width)));
actions.querySelectorAll("[data-image-align]").forEach((button) => button.classList.toggle("active", button.dataset.imageAlign === alignment));
requestAnimationFrame(positionInlineImageUi);
}
function setSelectedInlineImage(image) {
if (state.composeSelectedImage && state.composeSelectedImage !== image) {
state.composeSelectedImage.classList?.remove("compose-inline-image-selected");
@@ -648,6 +1174,7 @@ function setSelectedInlineImage(image) {
state.composeSelectedImage?.classList.add("compose-inline-image-selected");
const edit = $("#editor-image-edit");
if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage;
updateInlineImageActions();
}
function inlineImageAlignment(image) {
@@ -688,6 +1215,7 @@ function applyInlineImageDisplay(image, widthPercent, alignment) {
image.style.marginLeft = "0";
image.style.marginRight = "0";
}
updateInlineImageActions();
}
function imageCropValues() {
@@ -843,26 +1371,11 @@ async function applyInlineImageEditor(event) {
function scheduleUnusedInlineAttachmentCleanup() {
clearTimeout(state.inlineImageCleanupTimer);
state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500);
state.inlineImageCleanupTimer = setTimeout(syncComposeInlineAttachments, 250);
}
async function cleanupUnusedInlineAttachments() {
if (!state.composeDraftId || !state.inlineAttachmentIds.size) return;
const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean));
const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id)));
if (!unused.length) return;
let changed = false;
for (const id of unused) {
try {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id));
state.inlineAttachmentIds.delete(String(id));
changed = true;
} catch (error) {
console.warn("Could not remove unused inline attachment", id, error);
}
}
if (changed) renderComposeAttachments();
syncComposeInlineAttachments();
}
function editorRangeFromPoint(x, y) {
@@ -890,28 +1403,65 @@ async function uploadInlineImage(files, insertionRange = null) {
const uploaded = await uploadComposeFiles(images);
if (!uploaded.length) return;
setComposeMode("html");
const editor = $("#compose-editor");
restoreEditorRange(insertionRange, editor);
const draftId = ensureDraftId();
let lastInsertedId = null;
let range = insertionRange;
for (const attachment of uploaded) {
const previewUrl = composeAttachmentContentUrl(draftId, attachment.id);
state.inlineAttachmentIds.add(String(attachment.id));
lastInsertedId = String(attachment.id);
const html = `<img src="${escapeHtml(previewUrl)}" data-rustbox-cid="${escapeHtml(attachment.id)}" data-rustbox-align="inline" alt="${escapeHtml(attachment.filename || "image")}" style="max-width:100%;height:auto">`;
document.execCommand("insertHTML", false, html);
}
if (lastInsertedId) {
const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId);
if (inserted) setSelectedInlineImage(inserted);
insertComposeAttachment(attachment, range);
range = captureEditorRange();
}
syncComposeInlineAttachments();
scheduleDraftSave();
}
async function openImapDraftComposer(folder, uid) {
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
openCompose("new");
state.composeSignatureGeneration += 1;
state.composeDraftId = ensureDraftId();
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
inReplyTo: draft.in_reply_to || null,
references: draft.references || null,
};
$("#compose-to").value = draft.to || "";
$("#compose-cc").value = draft.cc || "";
$("#compose-bcc").value = draft.bcc || "";
$("#compose-subject").value = draft.subject || "";
if (draft.from_identity_id && $("#compose-from")) $("#compose-from").value = String(draft.from_identity_id);
$("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal";
$("#compose-mdn").checked = Boolean(draft.request_mdn);
$("#compose-pgp-mode").value = "none";
$("#compose-pgp-inline").checked = false;
$("#compose-pgp-attach-key").checked = false;
$("#compose-pgp-attach-recipient-keys").checked = false;
$("#compose-pgp-passphrase").value = "";
const identity = state.identities.find((item) => String(item.email || "").trim().toLowerCase() === String(draft.from_address || "").trim().toLowerCase());
if (identity && $("#compose-from")) $("#compose-from").value = String(identity.id);
updateComposePgpUi();
const mode = draft.compose_format === "html" && draft.html_body ? "html" : "text";
setComposeMode(mode);
const editor = $("#compose-editor");
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
else editor.textContent = draft.text_body || "";
renderComposeAttachments();
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
clearLocalDraftSnapshot(state.composeDraftId);
updateComposeDraftDeleteAction();
$("#compose-title").textContent = trText("Draft");
$("#compose-status").textContent = trText("Loaded from IMAP Drafts");
updateComposeMessageSize();
setTimeout(() => editor.focus(), 20);
}
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeSignatureGeneration += 1;
state.composeDraftId = draft.id || null;
state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : [];
state.composeRemoteDraft = draft.imap_source || null;
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
@@ -939,6 +1489,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeDirty = sourceLabel === "Browser recovery";
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot();
updateComposeDraftDeleteAction();
$("#compose-title").textContent = trText("Recovered draft");
$("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`;
setTimeout(() => editor.focus(), 20);
@@ -974,7 +1525,17 @@ async function recoverDraft() {
const id = selected.slice("local:".length);
const local = localDrafts.find((draft) => draft.id === id);
if (!local) return toast("Browser recovery snapshot is no longer available", "error");
applyRecoveredDraft(local, "Browser recovery");
let recovered = local;
try {
const server = await request(`/drafts/${encodeURIComponent(id)}`, { method: "GET" });
recovered = {
...local,
attachments: mergeComposeAttachments(local.attachments, server.attachments),
};
} catch (_) {
recovered = { ...local, attachments: mergeComposeAttachments(local.attachments) };
}
applyRecoveredDraft(recovered, "Browser recovery");
toast("Recovered unsaved browser draft", "success");
return;
}
@@ -1010,14 +1571,27 @@ function attachmentReminderNeeded() {
}
async function deleteComposeAttachment(id) {
const attachment = state.composeAttachments.find((item) => String(item.id) === String(id));
if (!attachment) return;
if (attachment.source === "imap") {
state.composeAttachments = state.composeAttachments.filter((item) => String(item.id) !== String(id));
state.inlineAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
});
renderComposeAttachments();
scheduleDraftSave();
return;
}
if (!state.composeDraftId) return;
try {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id);
state.inlineAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
if (image.dataset.rustboxCid === id) image.remove();
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
});
renderComposeAttachments();
scheduleDraftSave();
@@ -1058,6 +1632,23 @@ function toggleComposeFullscreen() {
setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen"));
}
function setComposeFieldsCollapsed(collapsed) {
const dialog = $("#compose-dialog");
const button = $("#compose-fields-toggle");
if (!dialog || !button) return;
const isCollapsed = Boolean(collapsed);
dialog.classList.toggle("compose-fields-collapsed", isCollapsed);
button.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
button.textContent = trText(isCollapsed ? "Show fields" : "Hide fields");
button.title = trText(isCollapsed ? "Show addressing fields" : "Hide addressing fields");
}
function toggleComposeFields() {
const collapse = !$("#compose-dialog").classList.contains("compose-fields-collapsed");
setComposeFieldsCollapsed(collapse);
if (collapse) $("#compose-editor")?.focus();
}
function openCompose(mode = "new") {
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
void forwardAsAttachment();
@@ -1069,15 +1660,18 @@ function openCompose(mode = "new") {
renderComposeIdentities();
if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail);
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.composeSignatureGeneration += 1;
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
setComposeFieldsCollapsed(false);
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.composeReplyMeta = null;
setComposeUploadProgress(false);
renderComposeAttachments();
updateComposeDraftDeleteAction();
$("#compose-error").classList.add("hidden");
$("#compose-status").textContent = "";
$("#compose-title").textContent = trText(mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message");
@@ -1202,11 +1796,19 @@ async function sendMessage(event) {
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked),
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked),
imap_source: state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null,
imap_attachments: state.composeRemoteDraft
? state.composeAttachments
.filter((attachment) => attachment?.source === "imap")
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0)
: [],
}),
});
$("#compose-dialog").close();
clearLocalDraftSnapshot(state.composeDraftId);
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
+13
View File
@@ -239,8 +239,21 @@ function suppressRealtimeNotificationsForFolder(folder, accountId = state.accoun
};
}
function realtimeFolderIsDrafts(folderName) {
const name = String(folderName || "");
if (!name) return false;
const configured = state.specialFolders?.drafts;
if (configured && name.toLowerCase() === String(configured).toLowerCase()) return true;
const meta = state.folders.find((folder) => String(folder?.name || "").toLowerCase() === name.toLowerCase());
const attrs = Array.isArray(meta?.attributes) ? meta.attributes : String(meta?.attributes || "").split(/\s+/);
if (attrs.some((value) => String(value).trim().toLowerCase() === "\\drafts")) return true;
const leaf = name.split(/[\/.]/).filter(Boolean).at(-1)?.toLowerCase();
return leaf === "draft" || leaf === "drafts";
}
function realtimeNewMailNotificationsSuppressed(data, accountId) {
if (!state.realtimeNotificationsReady) return true;
if (realtimeFolderIsDrafts(data?.folder)) return true;
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
}
+73 -7
View File
@@ -132,11 +132,49 @@ function bindEvents() {
$("#logout-btn").addEventListener("click", logout);
$("#compose-btn").addEventListener("click", () => openCompose("new"));
$("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen);
$("#compose-fields-toggle").addEventListener("click", toggleComposeFields);
$("#compose-to").addEventListener("invalid", () => setComposeFieldsCollapsed(false));
$("#compose-form").addEventListener("submit", sendMessage);
$("#compose-attach").addEventListener("click", () => $("#compose-files").click());
$("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; });
$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);
$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);
$("#compose-attach").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-files").click(); });
$("#compose-files").addEventListener("change", async (event) => {
const files = [...event.target.files];
const htmlMode = $("#compose-mode").value === "html";
const images = htmlMode ? files.filter((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : [];
const attachments = htmlMode ? files.filter((file) => !/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : files;
if (images.length) await uploadInlineImage(images, state.composeInsertRange);
if (attachments.length) await uploadComposeFiles(attachments);
state.composeInsertRange = null;
event.target.value = "";
});
$("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); });
$("#editor-image-edit").addEventListener("click", () => openInlineImageEditor());
$("#compose-image-actions").addEventListener("click", (event) => {
const image = state.composeSelectedImage;
if (!image) return;
const widthButton = event.target.closest("[data-image-width]");
const alignButton = event.target.closest("[data-image-align]");
if (widthButton) {
applyInlineImageDisplay(image, Number(widthButton.dataset.imageWidth), inlineImageAlignment(image));
scheduleDraftSave();
return;
}
if (alignButton) {
const currentWidth = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
applyInlineImageDisplay(image, currentWidth, alignButton.dataset.imageAlign);
scheduleDraftSave();
}
});
$("#compose-image-more").addEventListener("click", () => openInlineImageEditor());
$("#compose-image-detach").addEventListener("click", () => {
const image = state.composeSelectedImage;
if (!image) return;
image.remove();
setSelectedInlineImage(null);
syncComposeInlineAttachments();
scheduleDraftSave();
});
$("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; });
$("#image-edit-form").addEventListener("submit", applyInlineImageEditor);
$("#image-edit-reset").addEventListener("click", resetImageCropControls);
@@ -173,6 +211,9 @@ function bindEvents() {
updateComposeMessageSize();
}));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); updateComposeMessageSize(); });
$("#compose-editor").addEventListener("scroll", positionInlineImageUi, { passive: true });
window.addEventListener("resize", positionInlineImageUi, { passive: true });
$("#compose-image-resize").addEventListener("pointerdown", beginInlineImageResize);
$("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null);
@@ -192,14 +233,38 @@ function bindEvents() {
const range = captureEditorRange();
await uploadInlineImage(images, range);
});
$("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); });
$("#compose-editor").addEventListener("dragstart", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
if (!image || !event.dataTransfer) return;
setSelectedInlineImage(image);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-rustbox-inline-image", image.dataset.rustboxCid || "");
});
$("#compose-editor").addEventListener("dragover", (event) => {
const types = [...(event.dataTransfer?.types || [])];
if (types.includes("Files") || types.includes("application/x-rustbox-inline-image")) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = types.includes("Files") ? "copy" : "move";
}
});
$("#compose-editor").addEventListener("drop", async (event) => {
const files = [...event.dataTransfer.files];
const inlineCid = event.dataTransfer?.getData("application/x-rustbox-inline-image") || "";
const range = editorRangeFromPoint(event.clientX, event.clientY);
if (inlineCid) {
event.preventDefault();
const image = [...$("#compose-editor").querySelectorAll("img[data-rustbox-cid]")]
.find((candidate) => String(candidate.dataset.rustboxCid || "") === inlineCid);
if (image && range) moveInlineImageToRange(image, range);
return;
}
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return;
event.preventDefault();
const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type));
if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY));
else await uploadComposeFiles(files);
const htmlMode = $("#compose-mode").value === "html";
const images = htmlMode ? files.filter((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : [];
const attachments = htmlMode ? files.filter((file) => !/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : files;
if (images.length) await uploadInlineImage(images, range);
if (attachments.length) await uploadComposeFiles(attachments);
});
$("#reply-btn").addEventListener("click", () => openCompose("reply"));
$("#reply-all-btn").addEventListener("click", () => openCompose("reply-all"));
@@ -259,6 +324,7 @@ function bindEvents() {
} catch (error) { toast(error.message, "error"); }
});
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
$("#load-all").addEventListener("click", loadAllMessages);
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true });
window.addEventListener("offline", () => setConnectivityState(false));
window.addEventListener("online", async () => {