v0.0.4
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 00:23:11 +01:00
1 parent 93762fe9ef
commit 96a32ea8fb
17 files changed
+91 -34

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# RustBox changelog # 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 ### 0.0.3
- Fixed reader toolbar dropdowns for Message details and Move/copy by moving popovers out of the horizontally clipped toolbar layer. - 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. - Added Fastmail-, Apple Mail-, Yahoo Mail- and HEY-inspired styles plus Nord, and an optional per-account unlock for UI scaling on predefined styles.
Generated
+1 -1
View File
@@ -2957,7 +2957,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.0.3" version = "0.0.4"
dependencies = [ dependencies = [
"async-imap", "async-imap",
"async-native-tls", "async-native-tls",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.0.3" version = "0.0.4"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.88"
default-run = "rustbox" default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # 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` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+3 -3
View File
@@ -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`. 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. 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`. 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 ```bash
git init git init
git add . git add .
git commit -m "RustBox 0.0.3 release" git commit -m "RustBox 0.0.4 release"
./scripts/setup-upstream.sh ./scripts/setup-upstream.sh
``` ```
+5 -2
View File
@@ -1,4 +1,4 @@
RustBox 0.0.3 source release RustBox 0.0.4 source release
Runtime/source model: Runtime/source model:
- one root Cargo package - one root Cargo package
@@ -19,7 +19,10 @@ Oxi tracking:
- Oxi frontend changes are reviewed and manually ported to src/web/ - Oxi frontend changes are reviewed and manually ported to src/web/
- docs/UI.md documents the browser UI/build conventions - 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 - 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 - 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 - compact composer format selector moved to the lower-right opposite Send/Cancel
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.3 # RustBox 0.0.4
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
+2 -2
View File
@@ -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 ## Implemented / wired in this working snapshot
+2 -2
View File
@@ -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. 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 ### Search / notifications / diagnostics — 4
- [ ] Complete attachment content/metadata indexing policy and limits. - [ ] 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. - [ ] Richer per-account notification policy beyond global/per-folder controls.
- [ ] Background task/queue status plus recent-error/repair workflows. - [ ] Background task/queue status plus recent-error/repair workflows.
+11
View File
@@ -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.
+1 -1
View File
@@ -126,7 +126,7 @@ async fn https_get_address(
format!("{host}:{port}") format!("{host}:{port}")
}; };
let request = format!( 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())) timeout(duration, stream.write_all(request.as_bytes()))
.await .await
+1 -1
View File
@@ -192,7 +192,7 @@ pub async fn export_ics(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut body = String::from( 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 { for event in &events {
append_ics_event(&mut body, event); append_ics_event(&mut body, event);
+1 -1
View File
@@ -230,7 +230,7 @@ async fn post_one_click_to_address(
format!("{host}:{port}") format!("{host}:{port}")
}; };
let request = format!( 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() ONE_CLICK_BODY.len()
); );
+1 -1
View File
@@ -29,7 +29,7 @@ impl fmt::Display for SieveError {
write!(f, "ManageSieve server rejected the command: {msg}") write!(f, "ManageSieve server rejected the command: {msg}")
} }
Self::Timeout => write!(f, "ManageSieve operation timed out"), 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"),
} }
} }
} }
+5
View File
@@ -569,6 +569,11 @@
"Recovered draft": "Odzyskany szkic", "Recovered draft": "Odzyskany szkic",
"Forward as attachment": "Przekaż jako załącznik", "Forward as attachment": "Przekaż jako załącznik",
"Open": "Otwórz", "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ą", "Mark unread": "Oznacz jako nieprzeczytaną",
"Unflag": "Usuń oznaczenie", "Unflag": "Usuń oznaczenie",
"Move to…": "Przenieś do…", "Move to…": "Przenieś do…",
+44 -15
View File
@@ -326,10 +326,9 @@ function renderMessageDetail(detail) {
event.preventDefault(); event.preventDefault();
const filename = String(attachment.filename || "").toLowerCase(); const filename = String(attachment.filename || "").toLowerCase();
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
const actions = [ const actions = [];
{ label: "Open", action: () => openAttachment(detail.folder, detail.uid, attachment) }, if (attachmentCanPreview(attachment)) actions.push({ label: "Preview", action: () => openAttachment(detail.folder, detail.uid, attachment) });
{ label: "Download", action: () => downloadAttachment(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/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 (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) }); 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(); return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
} }
function attachmentCanPreview(attachment) { function attachmentPreviewLimitBytes(attachment) {
const mime = attachmentMime(attachment); const mime = attachmentMime(attachment);
const filename = String(attachment?.filename || "").toLowerCase(); const filename = String(attachment?.filename || "").toLowerCase();
return mime.startsWith("image/") if (mime.startsWith("text/") || /\.(txt|log|md|csv|html?|xhtml)$/i.test(filename)) return 5 * 1024 * 1024;
|| mime === "application/pdf" 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;
|| mime.startsWith("text/") return 0;
|| mime.startsWith("video/") }
|| /\.(pdf|png|jpe?g|gif|webp|txt|log|md|csv|html?|xhtml|mp4|webm|ogv|mov)$/i.test(filename);
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) { async function openAttachment(folder, uid, attachment) {
try { 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 blob = await fetchAttachmentBlob(folder, uid, attachment);
const mime = attachmentMime(attachment, blob); const mime = attachmentMime(attachment, blob);
const filename = String(attachment?.filename || "").toLowerCase(); const filename = String(attachment?.filename || "").toLowerCase();
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename); const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/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 isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename);
const safe = attachmentCanPreview({ ...attachment, content_type: mime }); const previewAttachment = { ...attachment, content_type: mime };
if (!safe) return toast("Preview is not available for this attachment type", "warning"); const safe = attachmentCanPreview(previewAttachment);
if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning"); 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 dialog = el("dialog", "dialog attachment-preview-dialog");
const card = el("div", "dialog-card attachment-preview-card"); const card = el("div", "dialog-card attachment-preview-card");
@@ -570,7 +601,6 @@ async function openAttachment(folder, uid, attachment) {
video.preload = "metadata"; video.preload = "metadata";
body.append(video); body.append(video);
} else if (isHtml) { } 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"); const frame = document.createElement("iframe");
frame.setAttribute("sandbox", ""); frame.setAttribute("sandbox", "");
frame.referrerPolicy = "no-referrer"; frame.referrerPolicy = "no-referrer";
@@ -579,7 +609,6 @@ async function openAttachment(folder, uid, attachment) {
frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`; frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
body.append(frame); body.append(frame);
} else { } else {
if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning");
const pre = document.createElement("pre"); const pre = document.createElement("pre");
pre.textContent = await blob.text(); pre.textContent = await blob.text();
body.append(pre); body.append(pre);
+5 -2
View File
@@ -102,6 +102,9 @@ def test_modular_js_contract():
assert "presetScaleUnlocked" in app assert "presetScaleUnlocked" in app
assert "positionToolbarMenu" in app assert "positionToolbarMenu" in app
assert "progressToast" 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 "appearancePresets" not in app
assert "applyAppearancePreset" not in app assert "applyAppearancePreset" not in app
assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text() 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"]: 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 assert f'"{value}"' in db_source
cargo = (ROOT / "Cargo.toml").read_text() 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() config = (ROOT / "src/config.rs").read_text()
assert "pub default_theme: String" in config assert "pub default_theme: String" in config
assert '"system" | "dark" | "light"' in config assert '"system" | "dark" | "light"' in config
@@ -138,7 +141,7 @@ def test_mobile_003_contract():
assert ".message-meta.addresses-expanded" in responsive assert ".message-meta.addresses-expanded" in responsive
pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text()) pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text())
assert len(pl.get("_text", {})) >= 450 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"] assert key in pl["_text"]
for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]: for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]:
assert key in pl assert key in pl