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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 23:52:18 +01:00
1 parent a116890e20
commit 02d0b13ec1
24 files changed
+245 -93

No files matched your search

+2 -6
View File
@@ -3,7 +3,7 @@ use futures::StreamExt;
use super::connection::map_imap_error;
use super::parse::{
decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email,
decode_header_bytes, flag_to_string, has_attachments, imap_address_to_email,
name_attribute_to_string,
};
@@ -407,11 +407,7 @@ impl ImapClient for RealImapClient {
.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() {
let subject = env
.subject
.as_ref()
.and_then(|b| std::str::from_utf8(b).ok())
.map(decode_rfc2047);
let subject = env.subject.as_ref().map(|b| decode_header_bytes(b));
let from: Vec<EmailAddress> = env
.from
+57 -11
View File
@@ -38,6 +38,19 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
return input.to_string();
}
// Let mail-parser handle RFC 2047 encoded words first. It supports a wider
// charset set than the small fallback below (including common Central
// European encodings used by older mail clients).
let synthetic = format!("Subject: {input}\r\n\r\n");
if let Some(parsed) = mail_parser::MessageParser::default().parse(synthetic.as_bytes())
&& let Some(subject) = parsed.subject()
{
let decoded = subject.to_string();
if !decoded.is_empty() && decoded != input {
return decoded;
}
}
let mut result = String::with_capacity(input.len());
let mut remaining = input;
@@ -112,23 +125,35 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
}
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
match charset.to_ascii_lowercase().as_str() {
"utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
"iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(),
_ => String::from_utf8(bytes.to_vec())
.unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()),
if let Some(encoding) = encoding_rs::Encoding::for_label(charset.trim().as_bytes()) {
let (decoded, _, _) = encoding.decode(bytes);
return decoded.into_owned();
}
String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect())
}
pub(crate) fn decode_header_bytes(bytes: &[u8]) -> String {
let mut synthetic = Vec::with_capacity(bytes.len() + 13);
synthetic.extend_from_slice(b"Subject: ");
synthetic.extend_from_slice(bytes);
synthetic.extend_from_slice(b"\r\n\r\n");
if let Some(parsed) = mail_parser::MessageParser::default().parse(&synthetic)
&& let Some(subject) = parsed.subject()
{
let decoded = subject.to_string();
if !decoded.is_empty() {
return decoded;
}
}
let text = String::from_utf8_lossy(bytes);
decode_rfc2047(&text)
}
pub(crate) fn imap_address_to_email(
addr: &async_imap::imap_proto::types::Address<'_>,
) -> EmailAddress {
let name = addr
.name
.as_ref()
.and_then(|b| std::str::from_utf8(b).ok())
.map(decode_rfc2047);
let name = addr.name.as_ref().map(|b| decode_header_bytes(b));
let mailbox = addr
.mailbox
@@ -173,3 +198,24 @@ pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<
BodyStructure::Multipart { bodies, .. } => bodies.iter().any(has_attachments),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_polish_rfc2047_subjects() {
assert_eq!(
decode_rfc2047("=?utf-8?b?WmHFvMOzxYLEhyBnxJnFm2zEhSBqYcW6xYQ=?="),
"Zażółć gęślą jaźń"
);
assert_eq!(
decode_rfc2047("=?iso-8859-2?q?Za=BF=F3=B3=E6_g=EA=B6l=B1_ja=BC=F1?="),
"Zażółć gęślą jaźń"
);
assert_eq!(
decode_rfc2047("=?windows-1250?b?WmG/87PmIGfqnGy5IGphn/E=?="),
"Zażółć gęślą jaźń"
);
}
}
+4 -6
View File
@@ -8,7 +8,9 @@ use tokio::task::JoinHandle;
use crate::config::AppConfig;
use crate::imap::client::ImapCredentials;
use crate::imap::connection::ImapStream;
use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email};
use crate::imap::parse::{
decode_header_bytes, flag_to_string, has_attachments, imap_address_to_email,
};
use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
use crate::{db, imap};
@@ -897,11 +899,7 @@ async fn fetch_and_store_headers(
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() {
let subject = env
.subject
.as_ref()
.and_then(|b| std::str::from_utf8(b).ok())
.map(decode_rfc2047);
let subject = env.subject.as_ref().map(|b| decode_header_bytes(b));
let from: Vec<_> = env
.from
+14 -5
View File
@@ -123,6 +123,7 @@ async fn add_security_headers(request: Request<Body>, next: middleware::Next) ->
let is_html = response_content_type_is(&response, "text/html")
|| response_content_type_is(&response, "application/xhtml+xml");
let is_css = response_content_type_is(&response, "text/css");
let is_pdf = response_content_type_is(&response, "application/pdf");
let headers = response.headers_mut();
headers.insert(
@@ -136,11 +137,19 @@ async fn add_security_headers(request: Request<Body>, next: middleware::Next) ->
"cross-origin-resource-policy",
HeaderValue::from_static("same-origin"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert(
"content-security-policy",
HeaderValue::from_static("frame-ancestors 'none'"),
);
if is_pdf {
headers.insert("x-frame-options", HeaderValue::from_static("SAMEORIGIN"));
headers.insert(
"content-security-policy",
HeaderValue::from_static("frame-ancestors 'self'"),
);
} else {
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert(
"content-security-policy",
HeaderValue::from_static("frame-ancestors 'none'"),
);
}
} else if is_html || is_css {
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
}
+34
View File
@@ -524,6 +524,40 @@ async fn security_headers_are_opt_in_and_content_type_aware() {
assert!(api.headers().get("cross-origin-opener-policy").is_none());
}
#[tokio::test]
async fn pdf_api_responses_allow_same_origin_preview_frames() {
let app = Router::new()
.route(
"/api/test.pdf",
get(|| async {
Response::builder()
.header("content-type", "application/pdf")
.body(Body::from("%PDF-1.4\n"))
.unwrap()
}),
)
.layer(middleware::from_fn(add_security_headers));
let response = app
.oneshot(
Request::builder()
.uri("/api/test.pdf")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.headers()["x-frame-options"], "SAMEORIGIN");
assert_eq!(
response.headers()["content-security-policy"],
"frame-ancestors 'self'"
);
assert_eq!(
response.headers()["cross-origin-resource-policy"],
"same-origin"
);
}
#[tokio::test]
async fn root_serves_embedded_rustbox_ui() {
let dir = setup_static_dir();
+15
View File
@@ -274,6 +274,21 @@
max-width: 760px;
}
.compose-limit-summary {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
font-size: 12px;
color: var(--muted);
}
.compose-limit-summary strong {
color: var(--text);
font-weight: 650;
}
.appearance-presets {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+4 -7
View File
@@ -885,13 +885,10 @@
message text suggests a missing attachment</span></label>
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
spellcheck while composing</span></label>
<label><span>Maximum attachment size</span><output
id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small
class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in
rustbox.toml.</small></label>
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__
MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA,
including base64/MIME overhead.</small></label>
<div class="compose-limit-summary" aria-label="Compose size limits">
<span>Maximum attachment size: <strong id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</strong></span>
<span>Maximum message size: <strong id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</strong></span>
</div>
</div>
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
plain-text fallback.</p>
+15
View File
@@ -155,6 +155,21 @@
return doc.body.innerHTML;
}
function sanitizeMessagePreviewHtml(html) {
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
doc.querySelectorAll("a[href]").forEach((node) => {
if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");
const target = String(node.getAttribute("target") || "").toLowerCase();
if (target && !["_self", "_parent", "_top"].includes(target)) {
const rel = new Set(String(node.getAttribute("rel") || "").split(/\s+/).filter(Boolean));
rel.add("noopener");
rel.add("noreferrer");
node.setAttribute("rel", [...rel].join(" "));
}
});
return doc.body.innerHTML;
}
function sanitizeAttachmentHtml(html) {
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href"));
+3 -3
View File
@@ -458,10 +458,10 @@ function showApp() {
$("#app-view").classList.remove("hidden");
}
function finishAuthenticatedStartup() {
function finishAuthenticatedStartup({ showLoginToast = false } = {}) {
state.startupToastsMuted = false;
armRealtimeNotifications();
toast(tr("login_success", "Successfully logged in"), "success");
if (showLoginToast) toast(tr("login_success", "Successfully logged in"), "success");
}
function prepareAuthenticatedStartup() {
@@ -735,7 +735,7 @@ async function login(event) {
await loadTrustedSenders();
startCalendarReminderWatcher();
connectRealtime();
finishAuthenticatedStartup();
finishAuthenticatedStartup({ showLoginToast: true });
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
+17 -9
View File
@@ -688,8 +688,8 @@ function renderMessageBody(detail, forceRemote = false) {
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}";
const safeHtml = sanitizeMessageHtml(detail.html);
iframe.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
const safeHtml = sanitizeMessagePreviewHtml(detail.html);
iframe.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
body.append(iframe);
}
@@ -722,6 +722,12 @@ function attachmentMime(attachment, blob = null) {
return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
}
function attachmentContentUrl(folder, uid, attachment) {
const path = apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`);
if (!state.accountId) return path;
return `${path}${path.includes("?") ? "&" : "?"}account_id=${encodeURIComponent(state.accountId)}`;
}
function attachmentPreviewLimitBytes(attachment) {
const mime = attachmentMime(attachment);
const filename = String(attachment?.filename || "").toLowerCase();
@@ -759,9 +765,11 @@ async function openAttachment(folder, uid, 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 declaredMime = attachmentMime(attachment);
const filename = String(attachment?.filename || "").toLowerCase();
const isPdf = declaredMime === "application/pdf" || filename.endsWith(".pdf");
const blob = isPdf ? null : await fetchAttachmentBlob(folder, uid, attachment);
const mime = isPdf ? "application/pdf" : attachmentMime(attachment, blob);
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);
@@ -771,7 +779,7 @@ async function openAttachment(folder, uid, attachment) {
await offerAttachmentDownload(folder, uid, attachment, "Preview is not available for this attachment type. Download it instead?");
return;
}
if (attachmentExceedsPreviewLimit(previewAttachment, blob.size)) {
if (blob && attachmentExceedsPreviewLimit(previewAttachment, blob.size)) {
await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?");
return;
}
@@ -794,12 +802,12 @@ async function openAttachment(folder, uid, attachment) {
image.src = objectUrl;
image.alt = attachment.filename || "Attachment image";
body.append(image);
} else if (mime === "application/pdf" || filename.endsWith(".pdf")) {
objectUrl = URL.createObjectURL(blob);
} else if (isPdf) {
const frame = document.createElement("iframe");
frame.src = objectUrl;
frame.src = attachmentContentUrl(folder, uid, attachment);
frame.title = attachment.filename || "PDF preview";
frame.referrerPolicy = "no-referrer";
frame.loading = "eager";
body.append(frame);
} else if (isVideo) {
objectUrl = URL.createObjectURL(blob);
@@ -815,7 +823,7 @@ async function openAttachment(folder, uid, attachment) {
frame.referrerPolicy = "no-referrer";
const safeHtml = sanitizeAttachmentHtml(await blob.text());
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
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><meta charset="utf-8">${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);
} else {
const pre = document.createElement("pre");