diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aaec7b..60e68eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # RustBox changelog +### 0.0.4 +- Changed attachment left-click behavior to prefer in-app preview for supported files while keeping direct download for non-previewable formats. +- Added attachment right-click actions for explicit Preview and Download. +- When an attachment exceeds the preview limit, RustBox now offers to download it instead of stopping at a warning; known oversized files are detected before their contents are fetched for preview. +- Bumped the application/source release to `0.0.4`. + ### 0.0.3 - Fixed reader toolbar dropdowns for Message details and Move/copy by moving popovers out of the horizontally clipped toolbar layer. - Added Fastmail-, Apple Mail-, Yahoo Mail- and HEY-inspired styles plus Nord, and an optional per-account unlock for UI scaling on predefined styles. diff --git a/Cargo.lock b/Cargo.lock index 7134646..a52c607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.0.3" +version = "0.0.4" dependencies = [ "async-imap", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index e9cae15..69b5d84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.0.3" +version = "0.0.4" edition = "2024" rust-version = "1.88" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 98391bc..c07a4f7 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.0.3 is derived from the Oxi webmail project: +RustBox 0.0.4 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 49d66ba..59fb6dd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.0.3 +# RustBox 0.0.4 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. @@ -71,7 +71,7 @@ If the mail host settings are omitted, RustBox uses `localhost` with IMAP `993` Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative. -## Current 0.0.3 UI snapshot +## Current 0.0.4 UI snapshot The internal baseline includes persistent appearance/layout preferences, draggable mail-pane splitters, action toasts, recipient autocomplete, attachment preview/context actions and client-side ZIP download in addition to the existing IMAP/Sieve/mail workflow. The authoritative completion scope remains `docs/RUSTBOX_PLAN.md`; incomplete items are tracked in `docs/IMPLEMENTATION_STATUS.md`. @@ -141,7 +141,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.0.3 release" +git commit -m "RustBox 0.0.4 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 4a695ac..f26c099 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,4 @@ -RustBox 0.0.3 source release +RustBox 0.0.4 source release Runtime/source model: - one root Cargo package @@ -19,7 +19,10 @@ Oxi tracking: - Oxi frontend changes are reviewed and manually ported to src/web/ - docs/UI.md documents the browser UI/build conventions -Current 0.0.3 release additions: +Current 0.0.4 release additions: +- attachment left-click defaults to safe in-app preview for supported types +- attachment right-click exposes explicit Preview and Download actions +- oversized preview attempts offer direct download instead of ending with a warning - Polish composer, reader toolbar and per-message context-menu translation pass - grouped reader message details menu for headers/source and transfer menu for move/copy - compact composer format selector moved to the lower-right opposite Send/Cancel diff --git a/config.example.toml b/config.example.toml index 1f5d4ff..183013c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.0.3 +# RustBox 0.0.4 host = "127.0.0.1" port = 3001 environment = "production" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1900a85..0c70ee1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,6 +1,6 @@ -# RustBox 0.0.3 - implementation status +# RustBox 0.0.4 - implementation status -This file tracks the internal `0.0.3` baseline. The user-approved `docs/RUSTBOX_PLAN.md` remains authoritative. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. +This file tracks the internal `0.0.4` baseline. The user-approved `docs/RUSTBOX_PLAN.md` remains authoritative. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. ## Implemented / wired in this working snapshot diff --git a/docs/ROADMAP_GAPS.md b/docs/ROADMAP_GAPS.md index aa8beac..0dee02c 100644 --- a/docs/ROADMAP_GAPS.md +++ b/docs/ROADMAP_GAPS.md @@ -1,4 +1,4 @@ -# RustBox 0.0.3 — remaining roadmap gaps +# RustBox 0.0.4 — remaining roadmap gaps Snapshot after the OpenPGP/key-lifecycle and unbounded Sieve action/condition work in this iteration. @@ -74,7 +74,7 @@ OpenPGP is functionally complete for the scope defined in `RUSTBOX_PLAN.md` §9: ### Search / notifications / diagnostics — 4 - [ ] Complete attachment content/metadata indexing policy and limits. -- [ ] Background/service-worker notifications while the page is inactive, if retained for 0.0.3. +- [ ] Background/service-worker notifications while the page is inactive, if retained for 0.0.4. - [ ] Richer per-account notification policy beyond global/per-folder controls. - [ ] Background task/queue status plus recent-error/repair workflows. diff --git a/docs/releases/v0.0.4.md b/docs/releases/v0.0.4.md new file mode 100644 index 0000000..afa6a4f --- /dev/null +++ b/docs/releases/v0.0.4.md @@ -0,0 +1,11 @@ +# RustBox 0.0.4 + +RustBox 0.0.4 improves attachment interaction in the message reader. + +## Highlights + +- Left-click previews supported attachment types in the in-app viewer. +- Right-click exposes explicit **Preview** and **Download** actions. +- Unsupported attachment types still download directly on left-click. +- Attachments that exceed the preview limit now offer **Download** instead of only showing a warning. +- Known oversized attachments are rejected before fetching their full payload for preview. diff --git a/src/pgp/discovery.rs b/src/pgp/discovery.rs index 9754678..00e4433 100644 --- a/src/pgp/discovery.rs +++ b/src/pgp/discovery.rs @@ -126,7 +126,7 @@ async fn https_get_address( format!("{host}:{port}") }; let request = format!( - "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.3\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" + "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.4\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" ); timeout(duration, stream.write_all(request.as_bytes())) .await diff --git a/src/routes/calendar.rs b/src/routes/calendar.rs index 8f4de96..7f17cc8 100644 --- a/src/routes/calendar.rs +++ b/src/routes/calendar.rs @@ -192,7 +192,7 @@ pub async fn export_ics( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let mut body = String::from( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.3//EN\r\nCALSCALE:GREGORIAN\r\n", + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.4//EN\r\nCALSCALE:GREGORIAN\r\n", ); for event in &events { append_ics_event(&mut body, event); diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs index d46ef70..37fcb62 100644 --- a/src/routes/unsubscribe.rs +++ b/src/routes/unsubscribe.rs @@ -230,7 +230,7 @@ async fn post_one_click_to_address( format!("{host}:{port}") }; let request = format!( - "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.3\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", + "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.4\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", ONE_CLICK_BODY.len() ); diff --git a/src/sieve/error.rs b/src/sieve/error.rs index 5c50603..4c41633 100644 --- a/src/sieve/error.rs +++ b/src/sieve/error.rs @@ -29,7 +29,7 @@ impl fmt::Display for SieveError { write!(f, "ManageSieve server rejected the command: {msg}") } Self::Timeout => write!(f, "ManageSieve operation timed out"), - Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.3 size limit"), + Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.4 size limit"), } } } diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 17483fc..4d26a50 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -569,6 +569,11 @@ "Recovered draft": "Odzyskany szkic", "Forward as attachment": "Przekaż jako załącznik", "Open": "Otwórz", + "Preview": "Podgląd", + "Download": "Pobierz", + "Preview unavailable": "Podgląd niedostępny", + "This attachment is too large for preview. Download it instead?": "Ten załącznik jest za duży do podglądu. Pobrać go zamiast tego?", + "Preview is not available for this attachment type. Download it instead?": "Podgląd nie jest dostępny dla tego typu załącznika. Pobrać go zamiast tego?", "Mark unread": "Oznacz jako nieprzeczytaną", "Unflag": "Usuń oznaczenie", "Move to…": "Przenieś do…", diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index 90ebaa7..d4a93e2 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -326,10 +326,9 @@ function renderMessageDetail(detail) { event.preventDefault(); const filename = String(attachment.filename || "").toLowerCase(); const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); - const actions = [ - { label: "Open", action: () => openAttachment(detail.folder, detail.uid, attachment) }, - { label: "Download", action: () => downloadAttachment(detail.folder, detail.uid, attachment) }, - ]; + const actions = []; + if (attachmentCanPreview(attachment)) actions.push({ label: "Preview", action: () => openAttachment(detail.folder, detail.uid, attachment) }); + actions.push({ label: "Download", action: () => downloadAttachment(detail.folder, detail.uid, attachment) }); if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label: "Add to calendar", action: () => importCalendarAttachment(detail.folder, detail.uid, attachment) }); if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label: "Import contact", action: () => importVcardAttachment(detail.folder, detail.uid, attachment) }); if ((detail.attachments || []).length > 1) actions.push("separator", { label: "Download all as ZIP", action: () => downloadAllAttachmentsZip(detail) }); @@ -514,27 +513,59 @@ function attachmentMime(attachment, blob = null) { return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase(); } -function attachmentCanPreview(attachment) { +function attachmentPreviewLimitBytes(attachment) { const mime = attachmentMime(attachment); const filename = String(attachment?.filename || "").toLowerCase(); - return mime.startsWith("image/") - || mime === "application/pdf" - || mime.startsWith("text/") - || mime.startsWith("video/") - || /\.(pdf|png|jpe?g|gif|webp|txt|log|md|csv|html?|xhtml|mp4|webm|ogv|mov)$/i.test(filename); + if (mime.startsWith("text/") || /\.(txt|log|md|csv|html?|xhtml)$/i.test(filename)) return 5 * 1024 * 1024; + if (mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("video/") || /\.(pdf|png|jpe?g|gif|webp|mp4|webm|ogv|mov)$/i.test(filename)) return 30 * 1024 * 1024; + return 0; +} + +function attachmentCanPreview(attachment) { + return attachmentPreviewLimitBytes(attachment) > 0; +} + +function attachmentExceedsPreviewLimit(attachment, actualSize = null) { + const limit = attachmentPreviewLimitBytes(attachment); + if (!limit) return false; + const size = Number(actualSize ?? attachment?.size ?? 0); + return Number.isFinite(size) && size > limit; +} + +async function offerAttachmentDownload(folder, uid, attachment, reason) { + const confirmed = await appConfirm(trText(reason), { + title: trText("Preview unavailable"), + confirmLabel: trText("Download"), + }); + if (confirmed) await downloadAttachment(folder, uid, attachment); } async function openAttachment(folder, uid, attachment) { try { + if (!attachmentCanPreview(attachment)) { + await offerAttachmentDownload(folder, uid, attachment, "Preview is not available for this attachment type. Download it instead?"); + return; + } + if (attachmentExceedsPreviewLimit(attachment)) { + await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?"); + return; + } const blob = await fetchAttachmentBlob(folder, uid, attachment); const mime = attachmentMime(attachment, blob); const filename = String(attachment?.filename || "").toLowerCase(); const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename); const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename); const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename); - const safe = attachmentCanPreview({ ...attachment, content_type: mime }); - if (!safe) return toast("Preview is not available for this attachment type", "warning"); - if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning"); + const previewAttachment = { ...attachment, content_type: mime }; + const safe = attachmentCanPreview(previewAttachment); + if (!safe) { + await offerAttachmentDownload(folder, uid, attachment, "Preview is not available for this attachment type. Download it instead?"); + return; + } + if (attachmentExceedsPreviewLimit(previewAttachment, blob.size)) { + await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?"); + return; + } const dialog = el("dialog", "dialog attachment-preview-dialog"); const card = el("div", "dialog-card attachment-preview-card"); @@ -570,7 +601,6 @@ async function openAttachment(folder, uid, attachment) { video.preload = "metadata"; body.append(video); } else if (isHtml) { - if (blob.size > 5 * 1024 * 1024) return toast("HTML attachment is too large for inline preview", "warning"); const frame = document.createElement("iframe"); frame.setAttribute("sandbox", ""); frame.referrerPolicy = "no-referrer"; @@ -579,7 +609,6 @@ async function openAttachment(folder, uid, attachment) { frame.srcdoc = `${csp}${safeHtml}`; body.append(frame); } else { - if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning"); const pre = document.createElement("pre"); pre.textContent = await blob.text(); body.append(pre); diff --git a/tests/roundcube_skin_contract.py b/tests/roundcube_skin_contract.py index ced1132..53e9a03 100644 --- a/tests/roundcube_skin_contract.py +++ b/tests/roundcube_skin_contract.py @@ -102,6 +102,9 @@ def test_modular_js_contract(): assert "presetScaleUnlocked" in app assert "positionToolbarMenu" in app assert "progressToast" in app + assert "attachmentPreviewLimitBytes" in app + assert 'label: "Preview"' in app + assert "This attachment is too large for preview. Download it instead?" in app assert "appearancePresets" not in app assert "applyAppearancePreset" not in app assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text() @@ -116,7 +119,7 @@ def test_backend_skin_contract_and_version(): for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord"]: assert f'"{value}"' in db_source cargo = (ROOT / "Cargo.toml").read_text() - assert 'version = "0.0.3"' in cargo + assert 'version = "0.0.4"' in cargo config = (ROOT / "src/config.rs").read_text() assert "pub default_theme: String" in config assert '"system" | "dark" | "light"' in config @@ -138,7 +141,7 @@ def test_mobile_003_contract(): assert ".message-meta.addresses-expanded" in responsive pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text()) assert len(pl.get("_text", {})) >= 450 - for key in ["Rule enabled", "Stop after this rule", "Calendar", "Message headers", "Message source", "Forward as attachment", "Show headers", "Show source", "Write a message"]: + for key in ["Rule enabled", "Stop after this rule", "Calendar", "Message headers", "Message source", "Forward as attachment", "Show headers", "Show source", "Write a message", "Preview", "Download", "Preview unavailable"]: assert key in pl["_text"] for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]: assert key in pl