feat: Strip remote data from emails when viewed #54

This commit is contained in:
rustmailer
2026-05-21 23:32:42 +08:00
parent 95147a7824
commit a3cdc094e8
11 changed files with 331 additions and 26 deletions
+27
View File
@@ -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<String>,
// all Attachments include inline attachments
pub attachments: Option<Vec<AttachmentInfo>>,
/// 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<Vec<AttachmentInfo>>,
/// 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<FullMessageContent> {
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<FullNestedMessageContent> {
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,
})
}
+182
View File
@@ -17,9 +17,63 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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 (<a href>).
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<Regex> = 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<Regex> = 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 <link> tags (stylesheets), never <a> links.
static LINK_HREF_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(<link\b[^>]*)\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<Regex> = 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 <style> blocks.
static IMPORT_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)@import\s+url\(\s*["']?\s*(?:https?://|//)[^)"'\s]*\s*["']?\s*\)\s*;"#,
)
.unwrap()
});
result = IMPORT_URL_RE.replace_all(&result, "").to_string();
// 6. Strip background attribute on <body> with remote URLs.
static BODY_BG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(<body\b[^>]*)\s+background\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#)
.unwrap()
});
result = BODY_BG_RE.replace_all(&result, "$1").to_string();
result
}
pub fn extract_text(html: String) -> String {
let result = panic::catch_unwind(|| {
html2text::config::plain()
@@ -88,4 +142,132 @@ mod tests {
let text = extract_text(html);
assert!(text.contains("Click here"));
}
mod block_remote {
use super::*;
#[test]
fn strips_img_src_http() {
let html = r#"<img src="https://tracker.example.com/pixel.gif" alt="x">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://tracker.example.com"));
assert!(result.contains("alt=")); // other attrs preserved
}
#[test]
fn strips_img_src_protocol_relative() {
let html = r#"<img src="//tracker.example.com/pixel.gif">"#;
let result = block_remote_content(html);
assert!(!result.contains("//tracker.example.com"));
}
#[test]
fn preserves_data_uri() {
let html = r#"<img src="data:image/png;base64,ABC123" alt="embedded">"#;
let result = block_remote_content(html);
assert!(result.contains("data:image/png;base64,ABC123"));
}
#[test]
fn preserves_cid_reference() {
let html = r#"<img src="cid:abc123@example.com" alt="inline">"#;
let result = block_remote_content(html);
assert!(result.contains("cid:abc123@example.com"));
}
#[test]
fn preserves_anchor_href() {
let html = r#"<a href="https://example.com/page">Click</a>"#;
let result = block_remote_content(html);
assert!(result.contains(r#"href="https://example.com/page""#));
}
#[test]
fn strips_link_stylesheet_href() {
let html =
r#"<link rel="stylesheet" href="https://fonts.example.com/font.css">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://fonts.example.com"));
assert!(result.contains("<link")); // tag preserved
}
#[test]
fn strips_script_src() {
let html = r#"<script src="https://evil.example.com/malware.js"></script>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://evil.example.com"));
}
#[test]
fn strips_iframe_src() {
let html = r#"<iframe src="https://ads.example.com/banner"></iframe>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://ads.example.com"));
}
#[test]
fn strips_css_url_in_style() {
let html = r#"<div style="background: url(https://tracker.example.com/bg.jpg)"></div>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://tracker.example.com"));
}
#[test]
fn strips_css_url_protocol_relative() {
let html = r#"<div style="background: url(//tracker.example.com/bg.jpg)"></div>"#;
let result = block_remote_content(html);
assert!(!result.contains("//tracker.example.com"));
}
#[test]
fn strips_css_import() {
let html =
r#"<style>@import url("https://fonts.example.com/font.css");</style>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://fonts.example.com"));
}
#[test]
fn strips_video_poster() {
let html = r#"<video poster="https://cdn.example.com/thumb.jpg"></video>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://cdn.example.com"));
}
#[test]
fn strips_srcset() {
let html =
r#"<img srcset="https://cdn.example.com/img1.jpg 1x, https://cdn.example.com/img2.jpg 2x">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://cdn.example.com"));
}
#[test]
fn strips_body_background() {
let html = r#"<body background="https://tracker.example.com/bg.jpg">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://tracker.example.com"));
assert!(result.contains("<body"));
}
#[test]
fn handles_mixed_content() {
let html = r#"
<html>
<body>
<img src="https://spy.example.com/pixel.gif" width="1" height="1">
<img src="data:image/png;base64,OK123" alt="ok">
<a href="https://example.com/read-more">Read more</a>
<div style="background: url(https://tracker.example.com/bg.jpg) no-repeat"></div>
</body>
</html>"#;
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""#));
}
}
}