From a3cdc094e8fba1083f9887220de20a8a4ffdda3f Mon Sep 17 00:00:00 2001 From: rustmailer Date: Thu, 21 May 2026 23:32:42 +0800 Subject: [PATCH] feat: Strip remote data from emails when viewed #54 --- Cargo.lock | 16 +- Cargo.toml | 6 +- crates/core/src/message/content.rs | 27 +++ crates/core/src/utils/html.rs | 182 ++++++++++++++++++ crates/server/src/rest/api/message.rs | 14 +- web/src/api/mailbox/envelope/api.ts | 24 ++- web/src/components/mail-iframe.tsx | 2 +- .../features/attachment/mail-message-view.tsx | 41 +++- .../attachment/nested-email-dialog.tsx | 2 +- web/src/features/search/mail-message-view.tsx | 41 +++- .../features/search/nested-email-dialog.tsx | 2 +- 11 files changed, 331 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd13052..a34b720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1006,9 +1006,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -2254,9 +2254,9 @@ checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libmimalloc-sys" -version = "0.1.47" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6" +checksum = "2892ae4ea6fa2cb7acb0e236a6880d39523239cd9089de71d220910ccc806790" dependencies = [ "cc", ] @@ -2467,9 +2467,9 @@ dependencies = [ [[package]] name = "mimalloc" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640" +checksum = "ebca48a43116bc25f18a61360f1be98412f50cc218f5e52c823086b999a4a21a" dependencies = [ "libmimalloc-sys", ] @@ -4286,9 +4286,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.1" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6" +checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581" dependencies = [ "libc", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 1819a8b..ee56a03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ edition = "2021" [workspace.dependencies] chrono = "0.4.44" clap = { version = "4.6.1", features = ["derive", "env"] } -mimalloc = "0.1.50" +mimalloc = "0.1.51" memdb = { path = "crates/memdb" } itertools = "0.14.0" ring = { version = "0.17.14", features = ["std"] } @@ -52,7 +52,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [ timeago = "0.6.0" oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] } url = { version = "2.5.8", features = ["serde"] } -sysinfo = "0.39.1" +sysinfo = "0.39.2" num_cpus = "1.17.0" rand = "0.10.1" encoding_rs = "0.8.35" @@ -73,7 +73,7 @@ time = { version = "0.3.47", features = [ rust-embed = "8.11.0" murmur3 = "0.5.2" urlencoding = "2.1.3" -dashmap = "6.1.0" +dashmap = "6.2.1" gethostname = "1.1.0" itoa = "1.0.18" html2text = "0.17.1" diff --git a/crates/core/src/message/content.rs b/crates/core/src/message/content.rs index 0451959..0f9fb4c 100644 --- a/crates/core/src/message/content.rs +++ b/crates/core/src/message/content.rs @@ -22,6 +22,7 @@ use crate::envelope::extractor::{extract_envelope_from_nested_message, reattach_ use crate::error::code::ErrorCode; use crate::store::envelope::Envelope; use crate::utils::compute_content_hash; +use crate::utils::html::block_remote_content; use crate::{error::BichonResult, raise_error}; use mail_parser::{MessageParser, MimeHeaders}; //use poem_openapi::Object; @@ -142,6 +143,9 @@ pub struct FullMessageContent { pub html: Option, // all Attachments include inline attachments pub attachments: Option>, + /// True when remote content (http/https URLs) was detected and stripped from html. + #[serde(default)] + pub has_remote_content: bool, } #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] @@ -155,11 +159,15 @@ pub struct FullNestedMessageContent { pub attachments: Option>, /// Metadata for the email envelope. pub envelope: Envelope, + /// True when remote content (http/https URLs) was detected and stripped from html. + #[serde(default)] + pub has_remote_content: bool, } pub fn retrieve_email_content( account_id: u64, envelope_id: String, + block_remote: bool, ) -> BichonResult { AccountModel::check_account_exists(account_id)?; let (envelope, eml) = reattach_eml_content(account_id, envelope_id)?; @@ -223,10 +231,19 @@ pub fn retrieve_email_content( content_id: attachment.content_id().map(Into::into), }); } + let mut has_remote_content = false; + if let Some(ref html_body) = html { + let filtered = block_remote_content(html_body); + has_remote_content = *html_body != filtered; + if block_remote { + html = Some(filtered); + } + } Ok(FullMessageContent { text, html, attachments: Some(attachments), + has_remote_content, }) } @@ -234,6 +251,7 @@ pub fn retrieve_nested_eml_content( account_id: u64, envelope_id: String, content_hash: &str, + block_remote: bool, ) -> BichonResult { let (_, eml) = reattach_eml_content(account_id, envelope_id)?; let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| { @@ -314,10 +332,19 @@ pub fn retrieve_nested_eml_content( let envelope = extract_envelope_from_nested_message(nested_message, account_id)?; + let mut has_remote_content = false; + if let Some(ref html_body) = html { + let filtered = block_remote_content(html_body); + has_remote_content = *html_body != filtered; + if block_remote { + html = Some(filtered); + } + } Ok(FullNestedMessageContent { text, html, attachments: Some(attachments), envelope, + has_remote_content, }) } diff --git a/crates/core/src/utils/html.rs b/crates/core/src/utils/html.rs index 6080dc4..029fea7 100644 --- a/crates/core/src/utils/html.rs +++ b/crates/core/src/utils/html.rs @@ -17,9 +17,63 @@ // along with this program. If not, see . +use regex::Regex; use std::panic; +use std::sync::LazyLock; use tracing::error; +/// Removes remote content references from HTML email body. +/// +/// Strips attributes that load content from http:// or https:// URLs, +/// keeping data: URIs and cid: references intact. Does NOT affect +/// navigation links (). +pub fn block_remote_content(html: &str) -> String { + let mut result = html.to_string(); + + // 1. Strip src, poster, data attributes with remote URLs. + // These always load content regardless of the tag. + static SRC_ATTR_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)\s+(src|poster|data)\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap() + }); + result = SRC_ATTR_RE.replace_all(&result, "").to_string(); + + // 2. Strip srcset attributes with remote URLs. + static SRCSET_ATTR_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)\s+srcset\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap() + }); + result = SRCSET_ATTR_RE.replace_all(&result, "").to_string(); + + // 3. Strip href on tags (stylesheets), never links. + static LINK_HREF_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)(]*)\s+href\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap() + }); + result = LINK_HREF_RE.replace_all(&result, "$1").to_string(); + + // 4. Strip CSS url() references with remote URLs in inline styles. + static CSS_URL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)url\(\s*["']?\s*(?:https?://|//)[^)"'\s]*\s*["']?\s*\)"#).unwrap() + }); + result = CSS_URL_RE.replace_all(&result, "").to_string(); + + // 5. Strip @import url(...) with remote URLs inside "#; + let result = block_remote_content(html); + assert!(!result.contains("https://fonts.example.com")); + } + + #[test] + fn strips_video_poster() { + let html = r#""#; + let result = block_remote_content(html); + assert!(!result.contains("https://cdn.example.com")); + } + + #[test] + fn strips_srcset() { + let html = + r#""#; + let result = block_remote_content(html); + assert!(!result.contains("https://cdn.example.com")); + } + + #[test] + fn strips_body_background() { + let html = r#""#; + let result = block_remote_content(html); + assert!(!result.contains("https://tracker.example.com")); + assert!(result.contains(" + + + ok + Read more +
+ + "#; + let result = block_remote_content(html); + // Remote content gone + assert!(!result.contains("spy.example.com")); + assert!(!result.contains("tracker.example.com")); + // Safe content preserved + assert!(result.contains("data:image/png;base64,OK123")); + assert!(result.contains(r#"href="https://example.com/read-more""#)); + } + } } diff --git a/crates/server/src/rest/api/message.rs b/crates/server/src/rest/api/message.rs index f426625..3c995de 100644 --- a/crates/server/src/rest/api/message.rs +++ b/crates/server/src/rest/api/message.rs @@ -121,6 +121,8 @@ impl MessageApi { } /// Fetches the content of a specific email. + /// Set `block_remote_content=true` to strip external images, scripts, + /// and other content loaded from http/https URLs. #[oai( path = "/message-content/:account_id/:envelope_id", method = "get", @@ -132,11 +134,18 @@ impl MessageApi { account_id: Path, /// The ID of the message to fetch. envelope_id: Path, + /// Block remote content (http/https URLs) from email body. + block_remote_content: Query>, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; + let block_remote = block_remote_content.0.unwrap_or(false); context.require_permission(Some(account_id), Permission::DATA_READ)?; - Ok(Json(retrieve_email_content(account_id, envelope_id.0)?)) + Ok(Json(retrieve_email_content( + account_id, + envelope_id.0, + block_remote, + )?)) } /// Retrieves the content of an email embedded as an attachment. @@ -152,15 +161,18 @@ impl MessageApi { /// The ID of the message to fetch. envelope_id: Path, content_hash: Query, + block_remote_content: Query>, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; + let block_remote = block_remote_content.0.unwrap_or(false); context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); Ok(Json(retrieve_nested_eml_content( account_id, envelope_id.0, content_hash, + block_remote, )?)) } diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index 552e9f6..a5a3dab 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -64,7 +64,8 @@ export interface AttachmentInfo { export interface MessageContentResponse { text?: string; html?: string; - attachments?: AttachmentInfo[] + attachments?: AttachmentInfo[]; + has_remote_content?: boolean; } export interface NestedMessageContentResponse { @@ -72,6 +73,7 @@ export interface NestedMessageContentResponse { html?: string; attachments?: AttachmentInfo[]; envelope: EmailEnvelope; + has_remote_content?: boolean; } export const getContent = (messageContent: MessageContentResponse): string | null => { @@ -83,13 +85,25 @@ export const getContent = (messageContent: MessageContentResponse): string | nul return null; }; -export const load_message = async (accountId: number, id: string) => { - const response = await axiosInstance.get(`api/v1/message-content/${accountId}/${id}`); +export const load_message = async (accountId: number, id: string, blockRemoteContent = false) => { + const params = new URLSearchParams(); + if (blockRemoteContent) { + params.set('block_remote_content', 'true'); + } + const qs = params.toString(); + const url = `api/v1/message-content/${accountId}/${id}${qs ? '?' + qs : ''}`; + const response = await axiosInstance.get(url); return response.data; }; -export const load_nested_message = async (accountId: number, id: string, content_hash: string) => { - const response = await axiosInstance.get(`api/v1/nested-message-content/${accountId}/${id}?content_hash=${content_hash}`); +export const load_nested_message = async (accountId: number, id: string, content_hash: string, blockRemoteContent = false) => { + const params = new URLSearchParams({ content_hash }); + if (blockRemoteContent) { + params.set('block_remote_content', 'true'); + } + const response = await axiosInstance.get( + `api/v1/nested-message-content/${accountId}/${id}?${params.toString()}` + ); return response.data; }; diff --git a/web/src/components/mail-iframe.tsx b/web/src/components/mail-iframe.tsx index 9a62c11..a3b3962 100644 --- a/web/src/components/mail-iframe.tsx +++ b/web/src/components/mail-iframe.tsx @@ -31,7 +31,7 @@ const EmailIframe: React.FC = ({ emailHtml, height }) => { return (