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]]
|
||||
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",
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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""#));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
/// The ID of the message to fetch.
|
||||
envelope_id: Path<String>,
|
||||
/// Block remote content (http/https URLs) from email body.
|
||||
block_remote_content: Query<Option<bool>>,
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<Json<FullMessageContent>> {
|
||||
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<String>,
|
||||
content_hash: Query<String>,
|
||||
block_remote_content: Query<Option<bool>>,
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<Json<FullNestedMessageContent>> {
|
||||
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,
|
||||
)?))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MessageContentResponse>(`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<MessageContentResponse>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const load_nested_message = async (accountId: number, id: string, content_hash: string) => {
|
||||
const response = await axiosInstance.get<NestedMessageContentResponse>(`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<NestedMessageContentResponse>(
|
||||
`api/v1/nested-message-content/${accountId}/${id}?${params.toString()}`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const EmailIframe: React.FC<EmailIframeProps> = ({ emailHtml, height }) => {
|
||||
return (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
sandbox=""
|
||||
className="w-full border-none"
|
||||
title="Email Content"
|
||||
style={{ height: height ?? '4000px' }}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { Separator } from '@/components/ui/separator';
|
||||
@@ -121,6 +121,12 @@ export function MailMessageView({
|
||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||
const { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
const [blockRemote, setBlockRemote] = useState(true);
|
||||
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
||||
|
||||
const toggleBlockRemote = () => {
|
||||
setBlockRemote((prev) => !prev);
|
||||
};
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||
@@ -137,12 +143,13 @@ export function MailMessageView({
|
||||
});
|
||||
|
||||
const loadMessageMutation = useMutation({
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
||||
onSuccess: (data) => {
|
||||
setLoading(false);
|
||||
setContent(getContent(data));
|
||||
if (data.attachments) setAttachments(data.attachments);
|
||||
setContentType(data.html ? 'Html' : 'Plain');
|
||||
setHasRemoteContent(!!data.has_remote_content);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false);
|
||||
@@ -154,10 +161,14 @@ export function MailMessageView({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
|
||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||
@@ -376,6 +387,30 @@ export function MailMessageView({
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
|
||||
@@ -150,7 +150,7 @@ export function NestedEmailDialog({ open, onOpenChange }: any) {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { Separator } from '@/components/ui/separator';
|
||||
@@ -129,6 +129,12 @@ export function MailMessageView({
|
||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||
const { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
const [blockRemote, setBlockRemote] = useState(true);
|
||||
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
||||
|
||||
const toggleBlockRemote = () => {
|
||||
setBlockRemote((prev) => !prev);
|
||||
};
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||
@@ -145,12 +151,13 @@ export function MailMessageView({
|
||||
});
|
||||
|
||||
const loadMessageMutation = useMutation({
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
||||
onSuccess: (data) => {
|
||||
setLoading(false);
|
||||
setContent(getContent(data));
|
||||
if (data.attachments) setAttachments(data.attachments);
|
||||
setContentType(data.html ? 'Html' : 'Plain');
|
||||
setHasRemoteContent(!!data.has_remote_content);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false);
|
||||
@@ -162,10 +169,14 @@ export function MailMessageView({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
|
||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||
@@ -384,6 +395,30 @@ export function MailMessageView({
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{loading ? (
|
||||
<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({
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user