From 5162f0a4784e08256bfe00125d24372cbcad3f0e Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Sat, 4 Jul 2026 17:03:03 +0800 Subject: [PATCH] fix: default block remote content loading --- CHANGELOG.md | 2 +- CHANGELOG_EN.md | 2 +- .../src/components/MailContentRenderer.vue | 24 +++---- frontend/src/i18n/locales/source/de.ts | 4 +- frontend/src/i18n/locales/source/es.ts | 4 +- frontend/src/i18n/locales/source/ja.ts | 4 +- frontend/src/i18n/locales/source/ptBR.ts | 4 +- frontend/src/i18n/message-registry.ts | 12 ++-- frontend/src/store/index.js | 4 +- frontend/src/utils/mail-html.js | 57 ---------------- frontend/src/utils/mail-html.ts | 66 +++++++++++++++++++ frontend/src/views/common/Appearance.vue | 6 +- 12 files changed, 99 insertions(+), 90 deletions(-) delete mode 100644 frontend/src/utils/mail-html.js create mode 100644 frontend/src/utils/mail-html.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 649a4e97..6fafaad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- feat: |Frontend| 外观设置新增“自动加载外部图片”开关,关闭后邮件 HTML 中的外链图片默认以占位图显示,并支持单封邮件临时加载外部图片(issue #1073) +- feat: |Frontend| 外观设置新增“自动加载远程内容”开关,默认关闭;关闭后邮件 HTML 中的远程图片默认以占位图显示,并支持单封邮件临时加载远程内容(issue #1073) ### Bug Fixes diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 64ad45b1..da8a7376 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,7 +10,7 @@ ### Features -- feat: |Frontend| Add an Appearance toggle for automatically loading external images; when disabled, HTML mail shows blocked-image placeholders by default and supports temporarily loading external images for a single mail (issue #1073) +- feat: |Frontend| Add an Appearance toggle for automatically loading remote content, disabled by default; when disabled, HTML mail shows blocked-image placeholders by default and supports temporarily loading remote content for a single mail (issue #1073) ### Bug Fixes diff --git a/frontend/src/components/MailContentRenderer.vue b/frontend/src/components/MailContentRenderer.vue index 6a7e68ca..1d172580 100644 --- a/frontend/src/components/MailContentRenderer.vue +++ b/frontend/src/components/MailContentRenderer.vue @@ -5,11 +5,11 @@ import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from import ShadowHtmlComponent from "./ShadowHtmlComponent.vue"; import AiExtractInfo from "./AiExtractInfo.vue"; import { getDownloadEmlUrl } from '../utils/email-parser'; -import { applyExternalImagePolicy } from '../utils/mail-html'; +import { applyRemoteContentPolicy } from '../utils/mail-html'; import { utcToLocalDate } from '../utils'; import { useGlobalState } from '../store'; -const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadExternalImages } = useGlobalState(); +const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteContent } = useGlobalState(); const { t } = useScopedI18n('components.MailContentRenderer') @@ -58,12 +58,12 @@ const showAttachments = ref(false); const curAttachments = ref([]); const attachmentLoding = ref(false); const showFullscreen = ref(false); -const loadExternalImagesForCurrentMail = ref(false); -const shouldLoadExternalImages = computed(() => autoLoadExternalImages.value || loadExternalImagesForCurrentMail.value); -const mailHtmlContent = computed(() => applyExternalImagePolicy(props.mail.message, shouldLoadExternalImages.value)); +const loadRemoteContentForCurrentMail = ref(false); +const shouldLoadRemoteContent = computed(() => autoLoadRemoteContent.value || loadRemoteContentForCurrentMail.value); +const mailHtmlContent = computed(() => applyRemoteContentPolicy(props.mail.message, shouldLoadRemoteContent.value)); watch(() => props.mail.id, () => { - loadExternalImagesForCurrentMail.value = false; + loadRemoteContentForCurrentMail.value = false; }); const handleDelete = () => { @@ -158,9 +158,9 @@ const handleSaveToS3 = async (filename, blob) => { {{ t('fullscreen') }} - - {{ t('loadExternalImages') }} + + {{ t('loadRemoteContent') }} @@ -180,9 +180,9 @@ const handleSaveToS3 = async (filename, blob) => { - - - {{ t('loadExternalImages') }} + + + {{ t('loadRemoteContent') }} diff --git a/frontend/src/i18n/locales/source/de.ts b/frontend/src/i18n/locales/source/de.ts index 3dc160ce..e1806cb0 100644 --- a/frontend/src/i18n/locales/source/de.ts +++ b/frontend/src/i18n/locales/source/de.ts @@ -178,7 +178,7 @@ export const deMessages = { "views.admin.SenderAccess.disable": "Deaktivieren", "views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert", "views.common.Appearance.preferShowTextMail": "Text-Mail standardmäßig anzeigen", - "views.common.Appearance.autoLoadExternalImages": "Externe Bilder automatisch laden", + "views.common.Appearance.autoLoadRemoteContent": "Remote-Inhalte automatisch laden", "views.admin.AccountSettings.domain_list": "Domain-Liste (optional)", "views.admin.AccountSettings.source_patterns_tip": "Die Domain-Liste filtert nach Empfängeradresse, die Quell-Regex nach Absenderadresse. Für die Weiterleitung müssen beide Bedingungen erfüllt sein (UND-Logik). Leer lassen, um den jeweiligen Filter zu überspringen.", "views.admin.UserManagement.domains": "Domains", @@ -238,7 +238,7 @@ export const deMessages = { "views.admin.AccountSettings.forward_address_required": "Eine Weiterleitungsadresse ist erforderlich", "views.admin.AccountSettings.forward_placeholder": "forward@example.com", "components.MailContentRenderer.fullscreen": "Vollbild", - "components.MailContentRenderer.loadExternalImages": "Externe Bilder laden", + "components.MailContentRenderer.loadRemoteContent": "Remote-Inhalte laden", "views.common.Login.generateName": "Zufälligen Namen erzeugen", "views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste", "views.common.Appearance.globalTabplacement": "Globale Tab-Position", diff --git a/frontend/src/i18n/locales/source/es.ts b/frontend/src/i18n/locales/source/es.ts index a6cf7397..cd449f46 100644 --- a/frontend/src/i18n/locales/source/es.ts +++ b/frontend/src/i18n/locales/source/es.ts @@ -178,7 +178,7 @@ export const esMessages = { "views.admin.SenderAccess.disable": "Deshabilitar", "views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada", "views.common.Appearance.preferShowTextMail": "Mostrar correo en texto por defecto", - "views.common.Appearance.autoLoadExternalImages": "Cargar imágenes externas automáticamente", + "views.common.Appearance.autoLoadRemoteContent": "Cargar contenido remoto automáticamente", "views.admin.AccountSettings.domain_list": "Lista de dominios (opcional)", "views.admin.AccountSettings.source_patterns_tip": "La lista de dominios filtra por destinatario y la regex de origen por remitente. Ambas condiciones deben cumplirse para reenviar (lógica AND). Deja alguna vacía para omitirla.", "views.admin.UserManagement.domains": "Dominios", @@ -238,7 +238,7 @@ export const esMessages = { "views.admin.AccountSettings.forward_address_required": "La dirección de reenvío es obligatoria", "views.admin.AccountSettings.forward_placeholder": "forward@example.com", "components.MailContentRenderer.fullscreen": "Pantalla completa", - "components.MailContentRenderer.loadExternalImages": "Cargar imágenes externas", + "components.MailContentRenderer.loadRemoteContent": "Cargar contenido remoto", "views.common.Login.generateName": "Generar nombre aleatorio", "views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos", "views.common.Appearance.globalTabplacement": "Posición global de pestañas", diff --git a/frontend/src/i18n/locales/source/ja.ts b/frontend/src/i18n/locales/source/ja.ts index 36a17435..646d3328 100644 --- a/frontend/src/i18n/locales/source/ja.ts +++ b/frontend/src/i18n/locales/source/ja.ts @@ -178,7 +178,7 @@ export const jaMessages = { "views.admin.SenderAccess.disable": "無効化", "views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化", "views.common.Appearance.preferShowTextMail": "既定でテキストメールを表示", - "views.common.Appearance.autoLoadExternalImages": "外部画像を自動で読み込む", + "views.common.Appearance.autoLoadRemoteContent": "リモートコンテンツを自動で読み込む", "views.admin.AccountSettings.domain_list": "ドメイン一覧(任意)", "views.admin.AccountSettings.source_patterns_tip": "ドメイン一覧は受信先アドレスで、送信元正規表現は送信者アドレスでフィルタします。転送には両方の条件を満たす必要があります(AND)。どちらかを空欄にするとその条件は無視されます。", "views.admin.UserManagement.domains": "ドメイン", @@ -238,7 +238,7 @@ export const jaMessages = { "views.admin.AccountSettings.forward_address_required": "転送先アドレスは必須です", "views.admin.AccountSettings.forward_placeholder": "forward@example.com", "components.MailContentRenderer.fullscreen": "全画面", - "components.MailContentRenderer.loadExternalImages": "外部画像を読み込む", + "components.MailContentRenderer.loadRemoteContent": "リモートコンテンツを読み込む", "views.common.Login.generateName": "ランダム名を生成", "views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧", "views.common.Appearance.globalTabplacement": "全体タブ位置", diff --git a/frontend/src/i18n/locales/source/ptBR.ts b/frontend/src/i18n/locales/source/ptBR.ts index 164e72ac..3588dd1e 100644 --- a/frontend/src/i18n/locales/source/ptBR.ts +++ b/frontend/src/i18n/locales/source/ptBR.ts @@ -178,7 +178,7 @@ export const ptBRMessages = { "views.admin.SenderAccess.disable": "Desativar", "views.Admin.loginViaDisabledCheck": "Verificação de senha desativada", "views.common.Appearance.preferShowTextMail": "Exibir e-mail em texto por padrão", - "views.common.Appearance.autoLoadExternalImages": "Carregar imagens externas automaticamente", + "views.common.Appearance.autoLoadRemoteContent": "Carregar conteúdo remoto automaticamente", "views.admin.AccountSettings.domain_list": "Lista de domínios (opcional)", "views.admin.AccountSettings.source_patterns_tip": "A lista de domínios filtra pelo destinatário e o regex de origem filtra pelo remetente. Ambas as condições precisam corresponder para encaminhar (lógica AND). Deixe qualquer uma vazia para ignorá-la.", "views.admin.UserManagement.domains": "Domínios", @@ -238,7 +238,7 @@ export const ptBRMessages = { "views.admin.AccountSettings.forward_address_required": "O endereço de encaminhamento é obrigatório", "views.admin.AccountSettings.forward_placeholder": "forward@example.com", "components.MailContentRenderer.fullscreen": "Tela cheia", - "components.MailContentRenderer.loadExternalImages": "Carregar imagens externas", + "components.MailContentRenderer.loadRemoteContent": "Carregar conteúdo remoto", "views.common.Login.generateName": "Gerar nome aleatório", "views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails", "views.common.Appearance.globalTabplacement": "Posição global das abas", diff --git a/frontend/src/i18n/message-registry.ts b/frontend/src/i18n/message-registry.ts index a9cf0536..960e0b2c 100644 --- a/frontend/src/i18n/message-registry.ts +++ b/frontend/src/i18n/message-registry.ts @@ -186,9 +186,9 @@ export const MESSAGE_REGISTRY = { "en": "Fullscreen", "zh": "全屏" }, - "loadExternalImages": { - "en": "Load External Images", - "zh": "加载外部图片" + "loadRemoteContent": { + "en": "Load Remote Content", + "zh": "加载远程内容" }, "reply": { "en": "Reply", @@ -2118,9 +2118,9 @@ export const MESSAGE_REGISTRY = { "en": "Display text Mail by default", "zh": "默认以文本显示邮件" }, - "autoLoadExternalImages": { - "en": "Automatically load external images", - "zh": "自动加载外部图片" + "autoLoadRemoteContent": { + "en": "Automatically load remote content", + "zh": "自动加载远程内容" }, "right": { "en": "right", diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 39820334..9c349ea1 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -90,7 +90,7 @@ export const useGlobalState = createGlobalState( const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25); const useIframeShowMail = useStorage('useIframeShowMail', false); const preferShowTextMail = useStorage('preferShowTextMail', false); - const autoLoadExternalImages = useStorage('autoLoadExternalImages', true); + const autoLoadRemoteContent = useStorage('autoLoadRemoteContent', false); const userJwt = useStorage('userJwt', ''); const preferredLocale = useStorage('preferredLocale', ''); const userTab = useSessionStorage('userTab', 'address_management'); @@ -163,7 +163,7 @@ export const useGlobalState = createGlobalState( mailboxSplitSize, useIframeShowMail, preferShowTextMail, - autoLoadExternalImages, + autoLoadRemoteContent, userJwt, preferredLocale, userTab, diff --git a/frontend/src/utils/mail-html.js b/frontend/src/utils/mail-html.js deleted file mode 100644 index 4a3de69c..00000000 --- a/frontend/src/utils/mail-html.js +++ /dev/null @@ -1,57 +0,0 @@ -const EXTERNAL_IMAGE_PLACEHOLDER = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(` - - - External image blocked - -`)}`; - -const isExternalImageUrl = (value) => { - if (!value) return false; - const normalized = value.trim().toLowerCase(); - return normalized.startsWith('http://') - || normalized.startsWith('https://') - || normalized.startsWith('//'); -}; - -const srcsetHasExternalUrl = (value) => { - if (!value) return false; - return value.split(',').some((item) => isExternalImageUrl(item.trim().split(/\s+/)[0])); -}; - -export const applyExternalImagePolicy = (html, autoLoadExternalImages) => { - if (autoLoadExternalImages || !html) { - return html || ''; - } - - if (typeof DOMParser !== 'function') { - return html; - } - - const hasDocumentShell = /]/i.test(html); - const doc = new DOMParser().parseFromString(html, 'text/html'); - - for (const image of doc.querySelectorAll('img')) { - const src = image.getAttribute('src') || ''; - const srcset = image.getAttribute('srcset') || ''; - - if (!isExternalImageUrl(src) && !srcsetHasExternalUrl(srcset)) { - continue; - } - - if (src) image.setAttribute('data-blocked-src', src); - if (srcset) image.setAttribute('data-blocked-srcset', srcset); - image.removeAttribute('srcset'); - image.setAttribute('src', EXTERNAL_IMAGE_PLACEHOLDER); - image.setAttribute('loading', 'lazy'); - image.style.maxWidth = '100%'; - image.style.height = 'auto'; - image.style.border = '1px solid #d1d5db'; - image.style.borderRadius = '8px'; - } - - if (hasDocumentShell) { - return `\n${doc.documentElement.outerHTML}`; - } - - return doc.body.innerHTML; -}; diff --git a/frontend/src/utils/mail-html.ts b/frontend/src/utils/mail-html.ts new file mode 100644 index 00000000..f4584084 --- /dev/null +++ b/frontend/src/utils/mail-html.ts @@ -0,0 +1,66 @@ +const REMOTE_CONTENT_PLACEHOLDER = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(` + + + Remote content blocked + +`)}`; + +const REMOTE_URL_PATTERN = /^(?:https?:)?\/\//i; +const DOCUMENT_SHELL_PATTERN = /]/i; + +const isRemoteUrl = (value: string | null) => { + return REMOTE_URL_PATTERN.test(value?.trim() || ''); +}; + +const hasRemoteSrcset = (value: string | null) => { + if (!value) return false; + + return value.split(',') + .some((candidate) => isRemoteUrl(candidate.trim().split(/\s+/)[0] || null)); +}; + +const shouldBlockImage = (image: HTMLImageElement) => { + return isRemoteUrl(image.getAttribute('src')) + || hasRemoteSrcset(image.getAttribute('srcset')); +}; + +const blockImage = (image: HTMLImageElement) => { + const src = image.getAttribute('src'); + const srcset = image.getAttribute('srcset'); + + if (src) image.setAttribute('data-blocked-src', src); + if (srcset) image.setAttribute('data-blocked-srcset', srcset); + + image.removeAttribute('srcset'); + image.setAttribute('src', REMOTE_CONTENT_PLACEHOLDER); + image.setAttribute('loading', 'lazy'); + image.style.maxWidth = '100%'; + image.style.height = 'auto'; + image.style.border = '1px solid #d1d5db'; + image.style.borderRadius = '8px'; +}; + +const serializeMailHtml = (doc: Document, hasDocumentShell: boolean) => { + if (hasDocumentShell) { + return `\n${doc.documentElement.outerHTML}`; + } + + return doc.body.innerHTML; +}; + +export const applyRemoteContentPolicy = (html: string | null | undefined, loadRemoteContent: boolean) => { + const mailHtml = html || ''; + if (loadRemoteContent || !mailHtml) return mailHtml; + if (typeof DOMParser !== 'function') return mailHtml; + + const hasDocumentShell = DOCUMENT_SHELL_PATTERN.test(mailHtml); + const doc = new DOMParser().parseFromString(mailHtml, 'text/html'); + + for (const image of doc.querySelectorAll('img')) { + if (shouldBlockImage(image)) { + blockImage(image); + } + } + + return serializeMailHtml(doc, hasDocumentShell); +}; diff --git a/frontend/src/views/common/Appearance.vue b/frontend/src/views/common/Appearance.vue index 111e03b9..63f84c87 100644 --- a/frontend/src/views/common/Appearance.vue +++ b/frontend/src/views/common/Appearance.vue @@ -12,7 +12,7 @@ const props = defineProps({ const { mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval, - globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadExternalImages + globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteContent } = useGlobalState() const isMobile = useIsMobile() @@ -43,8 +43,8 @@ const { t } = useScopedI18n('views.common.Appearance') - - + +