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
Generated
+8 -8
View File
@@ -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
View File
@@ -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"
+27
View File
@@ -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,
}) })
} }
+182
View File
@@ -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""#));
}
}
} }
+13 -1
View File
@@ -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,
)?)) )?))
} }
+19 -5
View File
@@ -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;
}; };
+1 -1
View File
@@ -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,
}); });
+38 -3
View File
@@ -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,
}); });