mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: Strip remote data from emails when viewed #54
This commit is contained in:
Generated
+8
-8
@@ -1006,9 +1006,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.1.0"
|
version = "6.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
@@ -2254,9 +2254,9 @@ checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libmimalloc-sys"
|
name = "libmimalloc-sys"
|
||||||
version = "0.1.47"
|
version = "0.1.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
|
checksum = "2892ae4ea6fa2cb7acb0e236a6880d39523239cd9089de71d220910ccc806790"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
@@ -2467,9 +2467,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mimalloc"
|
name = "mimalloc"
|
||||||
version = "0.1.50"
|
version = "0.1.51"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
|
checksum = "ebca48a43116bc25f18a61360f1be98412f50cc218f5e52c823086b999a4a21a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libmimalloc-sys",
|
"libmimalloc-sys",
|
||||||
]
|
]
|
||||||
@@ -4286,9 +4286,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.39.1"
|
version = "0.39.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
|
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|||||||
+3
-3
@@ -17,7 +17,7 @@ edition = "2021"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||||
mimalloc = "0.1.50"
|
mimalloc = "0.1.51"
|
||||||
memdb = { path = "crates/memdb" }
|
memdb = { path = "crates/memdb" }
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
ring = { version = "0.17.14", features = ["std"] }
|
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"
|
timeago = "0.6.0"
|
||||||
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
||||||
url = { version = "2.5.8", features = ["serde"] }
|
url = { version = "2.5.8", features = ["serde"] }
|
||||||
sysinfo = "0.39.1"
|
sysinfo = "0.39.2"
|
||||||
num_cpus = "1.17.0"
|
num_cpus = "1.17.0"
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
encoding_rs = "0.8.35"
|
encoding_rs = "0.8.35"
|
||||||
@@ -73,7 +73,7 @@ time = { version = "0.3.47", features = [
|
|||||||
rust-embed = "8.11.0"
|
rust-embed = "8.11.0"
|
||||||
murmur3 = "0.5.2"
|
murmur3 = "0.5.2"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.2.1"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
itoa = "1.0.18"
|
itoa = "1.0.18"
|
||||||
html2text = "0.17.1"
|
html2text = "0.17.1"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::envelope::extractor::{extract_envelope_from_nested_message, reattach_
|
|||||||
use crate::error::code::ErrorCode;
|
use crate::error::code::ErrorCode;
|
||||||
use crate::store::envelope::Envelope;
|
use crate::store::envelope::Envelope;
|
||||||
use crate::utils::compute_content_hash;
|
use crate::utils::compute_content_hash;
|
||||||
|
use crate::utils::html::block_remote_content;
|
||||||
use crate::{error::BichonResult, raise_error};
|
use crate::{error::BichonResult, raise_error};
|
||||||
use mail_parser::{MessageParser, MimeHeaders};
|
use mail_parser::{MessageParser, MimeHeaders};
|
||||||
//use poem_openapi::Object;
|
//use poem_openapi::Object;
|
||||||
@@ -142,6 +143,9 @@ pub struct FullMessageContent {
|
|||||||
pub html: Option<String>,
|
pub html: Option<String>,
|
||||||
// all Attachments include inline attachments
|
// all Attachments include inline attachments
|
||||||
pub attachments: Option<Vec<AttachmentInfo>>,
|
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)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
@@ -155,11 +159,15 @@ pub struct FullNestedMessageContent {
|
|||||||
pub attachments: Option<Vec<AttachmentInfo>>,
|
pub attachments: Option<Vec<AttachmentInfo>>,
|
||||||
/// Metadata for the email envelope.
|
/// Metadata for the email envelope.
|
||||||
pub envelope: 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(
|
pub fn retrieve_email_content(
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
envelope_id: String,
|
envelope_id: String,
|
||||||
|
block_remote: bool,
|
||||||
) -> BichonResult<FullMessageContent> {
|
) -> BichonResult<FullMessageContent> {
|
||||||
AccountModel::check_account_exists(account_id)?;
|
AccountModel::check_account_exists(account_id)?;
|
||||||
let (envelope, eml) = reattach_eml_content(account_id, envelope_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),
|
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 {
|
Ok(FullMessageContent {
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
attachments: Some(attachments),
|
attachments: Some(attachments),
|
||||||
|
has_remote_content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +251,7 @@ pub fn retrieve_nested_eml_content(
|
|||||||
account_id: u64,
|
account_id: u64,
|
||||||
envelope_id: String,
|
envelope_id: String,
|
||||||
content_hash: &str,
|
content_hash: &str,
|
||||||
|
block_remote: bool,
|
||||||
) -> BichonResult<FullNestedMessageContent> {
|
) -> BichonResult<FullNestedMessageContent> {
|
||||||
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
|
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||||
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
|
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 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 {
|
Ok(FullNestedMessageContent {
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
attachments: Some(attachments),
|
attachments: Some(attachments),
|
||||||
envelope,
|
envelope,
|
||||||
|
has_remote_content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,63 @@
|
|||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
use std::panic;
|
use std::panic;
|
||||||
|
use std::sync::LazyLock;
|
||||||
use tracing::error;
|
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 {
|
pub fn extract_text(html: String) -> String {
|
||||||
let result = panic::catch_unwind(|| {
|
let result = panic::catch_unwind(|| {
|
||||||
html2text::config::plain()
|
html2text::config::plain()
|
||||||
@@ -88,4 +142,132 @@ mod tests {
|
|||||||
let text = extract_text(html);
|
let text = extract_text(html);
|
||||||
assert!(text.contains("Click here"));
|
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""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ impl MessageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches the content of a specific email.
|
/// 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(
|
#[oai(
|
||||||
path = "/message-content/:account_id/:envelope_id",
|
path = "/message-content/:account_id/:envelope_id",
|
||||||
method = "get",
|
method = "get",
|
||||||
@@ -132,11 +134,18 @@ impl MessageApi {
|
|||||||
account_id: Path<u64>,
|
account_id: Path<u64>,
|
||||||
/// The ID of the message to fetch.
|
/// The ID of the message to fetch.
|
||||||
envelope_id: Path<String>,
|
envelope_id: Path<String>,
|
||||||
|
/// Block remote content (http/https URLs) from email body.
|
||||||
|
block_remote_content: Query<Option<bool>>,
|
||||||
context: WrappedContext,
|
context: WrappedContext,
|
||||||
) -> ApiResult<Json<FullMessageContent>> {
|
) -> ApiResult<Json<FullMessageContent>> {
|
||||||
let account_id = account_id.0;
|
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)?;
|
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.
|
/// Retrieves the content of an email embedded as an attachment.
|
||||||
@@ -152,15 +161,18 @@ impl MessageApi {
|
|||||||
/// The ID of the message to fetch.
|
/// The ID of the message to fetch.
|
||||||
envelope_id: Path<String>,
|
envelope_id: Path<String>,
|
||||||
content_hash: Query<String>,
|
content_hash: Query<String>,
|
||||||
|
block_remote_content: Query<Option<bool>>,
|
||||||
context: WrappedContext,
|
context: WrappedContext,
|
||||||
) -> ApiResult<Json<FullNestedMessageContent>> {
|
) -> ApiResult<Json<FullNestedMessageContent>> {
|
||||||
let account_id = account_id.0;
|
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)?;
|
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||||
let content_hash = content_hash.0.trim();
|
let content_hash = content_hash.0.trim();
|
||||||
Ok(Json(retrieve_nested_eml_content(
|
Ok(Json(retrieve_nested_eml_content(
|
||||||
account_id,
|
account_id,
|
||||||
envelope_id.0,
|
envelope_id.0,
|
||||||
content_hash,
|
content_hash,
|
||||||
|
block_remote,
|
||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export interface AttachmentInfo {
|
|||||||
export interface MessageContentResponse {
|
export interface MessageContentResponse {
|
||||||
text?: string;
|
text?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
attachments?: AttachmentInfo[]
|
attachments?: AttachmentInfo[];
|
||||||
|
has_remote_content?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NestedMessageContentResponse {
|
export interface NestedMessageContentResponse {
|
||||||
@@ -72,6 +73,7 @@ export interface NestedMessageContentResponse {
|
|||||||
html?: string;
|
html?: string;
|
||||||
attachments?: AttachmentInfo[];
|
attachments?: AttachmentInfo[];
|
||||||
envelope: EmailEnvelope;
|
envelope: EmailEnvelope;
|
||||||
|
has_remote_content?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getContent = (messageContent: MessageContentResponse): string | null => {
|
export const getContent = (messageContent: MessageContentResponse): string | null => {
|
||||||
@@ -83,13 +85,25 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const load_message = async (accountId: number, id: string) => {
|
export const load_message = async (accountId: number, id: string, blockRemoteContent = false) => {
|
||||||
const response = await axiosInstance.get<MessageContentResponse>(`api/v1/message-content/${accountId}/${id}`);
|
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<MessageContentResponse>(url);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const load_nested_message = async (accountId: number, id: string, content_hash: string) => {
|
export const load_nested_message = async (accountId: number, id: string, content_hash: string, blockRemoteContent = false) => {
|
||||||
const response = await axiosInstance.get<NestedMessageContentResponse>(`api/v1/nested-message-content/${accountId}/${id}?content_hash=${content_hash}`);
|
const params = new URLSearchParams({ content_hash });
|
||||||
|
if (blockRemoteContent) {
|
||||||
|
params.set('block_remote_content', 'true');
|
||||||
|
}
|
||||||
|
const response = await axiosInstance.get<NestedMessageContentResponse>(
|
||||||
|
`api/v1/nested-message-content/${accountId}/${id}?${params.toString()}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const EmailIframe: React.FC<EmailIframeProps> = ({ emailHtml, height }) => {
|
|||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
src={iframeSrc}
|
src={iframeSrc}
|
||||||
sandbox="allow-scripts"
|
sandbox=""
|
||||||
className="w-full border-none"
|
className="w-full border-none"
|
||||||
title="Email Content"
|
title="Email Content"
|
||||||
style={{ height: height ?? '4000px' }}
|
style={{ height: height ?? '4000px' }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -121,6 +121,12 @@ export function MailMessageView({
|
|||||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||||
const { getEmailById } = useMinimalAccountList();
|
const { getEmailById } = useMinimalAccountList();
|
||||||
const [threadOpen, setThreadOpen] = useState(false);
|
const [threadOpen, setThreadOpen] = useState(false);
|
||||||
|
const [blockRemote, setBlockRemote] = useState(true);
|
||||||
|
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
||||||
|
|
||||||
|
const toggleBlockRemote = () => {
|
||||||
|
setBlockRemote((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
const downloadAttachmentMutation = useMutation({
|
const downloadAttachmentMutation = useMutation({
|
||||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||||
@@ -137,12 +143,13 @@ export function MailMessageView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loadMessageMutation = useMutation({
|
const loadMessageMutation = useMutation({
|
||||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setContent(getContent(data));
|
setContent(getContent(data));
|
||||||
if (data.attachments) setAttachments(data.attachments);
|
if (data.attachments) setAttachments(data.attachments);
|
||||||
setContentType(data.html ? 'Html' : 'Plain');
|
setContentType(data.html ? 'Html' : 'Plain');
|
||||||
|
setHasRemoteContent(!!data.has_remote_content);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -154,10 +161,14 @@ export function MailMessageView({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBlockRemote(true);
|
||||||
|
}, [envelope.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadMessageMutation.mutate();
|
loadMessageMutation.mutate();
|
||||||
}, [envelope.id]);
|
}, [envelope.id, blockRemote]);
|
||||||
|
|
||||||
|
|
||||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||||
@@ -376,6 +387,30 @@ export function MailMessageView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAttachments && <Separator className="mb-2" />}
|
{showAttachments && <Separator className="mb-2" />}
|
||||||
|
{hasRemoteContent && (
|
||||||
|
<div className="flex items-center justify-between bg-muted border px-3 py-1.5 mb-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
{blockRemote ? (
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{t('mail.remoteBlocked', 'To protect your privacy, Bichon has blocked remote content in this message.')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{t('mail.remoteShown', 'Remote content is now shown.')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="underline cursor-pointer hover:no-underline text-muted-foreground text-[11px] font-medium shrink-0 ml-2 select-none"
|
||||||
|
onClick={toggleBlockRemote}
|
||||||
|
>
|
||||||
|
{blockRemote
|
||||||
|
? t('mail.showRemoteContent', 'Show remote content')
|
||||||
|
: t('mail.blockRemoteAgain', 'Block again')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function NestedEmailDialog({ open, onOpenChange }: any) {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['nested-message', currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!],
|
queryKey: ['nested-message', currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!],
|
||||||
queryFn: () => load_nested_message(currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!),
|
queryFn: () => load_nested_message(currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!, true),
|
||||||
enabled: open && !!currentAttachment,
|
enabled: open && !!currentAttachment,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -129,6 +129,12 @@ export function MailMessageView({
|
|||||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||||
const { getEmailById } = useMinimalAccountList();
|
const { getEmailById } = useMinimalAccountList();
|
||||||
const [threadOpen, setThreadOpen] = useState(false);
|
const [threadOpen, setThreadOpen] = useState(false);
|
||||||
|
const [blockRemote, setBlockRemote] = useState(true);
|
||||||
|
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
||||||
|
|
||||||
|
const toggleBlockRemote = () => {
|
||||||
|
setBlockRemote((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
const downloadAttachmentMutation = useMutation({
|
const downloadAttachmentMutation = useMutation({
|
||||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||||
@@ -145,12 +151,13 @@ export function MailMessageView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loadMessageMutation = useMutation({
|
const loadMessageMutation = useMutation({
|
||||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setContent(getContent(data));
|
setContent(getContent(data));
|
||||||
if (data.attachments) setAttachments(data.attachments);
|
if (data.attachments) setAttachments(data.attachments);
|
||||||
setContentType(data.html ? 'Html' : 'Plain');
|
setContentType(data.html ? 'Html' : 'Plain');
|
||||||
|
setHasRemoteContent(!!data.has_remote_content);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -162,10 +169,14 @@ export function MailMessageView({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBlockRemote(true);
|
||||||
|
}, [envelope.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadMessageMutation.mutate();
|
loadMessageMutation.mutate();
|
||||||
}, [envelope.id]);
|
}, [envelope.id, blockRemote]);
|
||||||
|
|
||||||
|
|
||||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||||
@@ -384,6 +395,30 @@ export function MailMessageView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAttachments && <Separator className="mb-2" />}
|
{showAttachments && <Separator className="mb-2" />}
|
||||||
|
{hasRemoteContent && (
|
||||||
|
<div className="flex items-center justify-between bg-muted border px-3 py-1.5 mb-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
{blockRemote ? (
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{t('mail.remoteBlocked', 'To protect your privacy, Bichon has blocked remote content in this message.')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{t('mail.remoteShown', 'Remote content is now shown.')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="underline cursor-pointer hover:no-underline text-muted-foreground text-[11px] font-medium shrink-0 ml-2 select-none"
|
||||||
|
onClick={toggleBlockRemote}
|
||||||
|
>
|
||||||
|
{blockRemote
|
||||||
|
? t('mail.showRemoteContent', 'Show remote content')
|
||||||
|
: t('mail.blockRemoteAgain', 'Block again')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function NestedEmailDialog({ open, onOpenChange, accountId, envelopeId, f
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['nested-message', accountId, envelopeId, content_hash],
|
queryKey: ['nested-message', accountId, envelopeId, content_hash],
|
||||||
queryFn: () => load_nested_message(accountId, envelopeId, content_hash),
|
queryFn: () => load_nested_message(accountId, envelopeId, content_hash, true),
|
||||||
enabled: open && !!content_hash,
|
enabled: open && !!content_hash,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user