fix: default block remote content loading

This commit is contained in:
dreamhunter2333
2026-07-04 17:03:03 +08:00
parent e06b7ccbb2
commit 5162f0a478
12 changed files with 99 additions and 90 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
### Features ### Features
- feat: |Frontend| 外观设置新增“自动加载外部图片”开关,关闭后邮件 HTML 中的外链图片默认以占位图显示,并支持单封邮件临时加载外部图片issue #1073 - feat: |Frontend| 外观设置新增“自动加载远程内容”开关,默认关闭;关闭后邮件 HTML 中的远程图片默认以占位图显示,并支持单封邮件临时加载远程内容issue #1073
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -10,7 +10,7 @@
### Features ### 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 ### Bug Fixes
+12 -12
View File
@@ -5,11 +5,11 @@ import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue"; import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import AiExtractInfo from "./AiExtractInfo.vue"; import AiExtractInfo from "./AiExtractInfo.vue";
import { getDownloadEmlUrl } from '../utils/email-parser'; import { getDownloadEmlUrl } from '../utils/email-parser';
import { applyExternalImagePolicy } from '../utils/mail-html'; import { applyRemoteContentPolicy } from '../utils/mail-html';
import { utcToLocalDate } from '../utils'; import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store'; import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadExternalImages } = useGlobalState(); const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteContent } = useGlobalState();
const { t } = useScopedI18n('components.MailContentRenderer') const { t } = useScopedI18n('components.MailContentRenderer')
@@ -58,12 +58,12 @@ const showAttachments = ref(false);
const curAttachments = ref([]); const curAttachments = ref([]);
const attachmentLoding = ref(false); const attachmentLoding = ref(false);
const showFullscreen = ref(false); const showFullscreen = ref(false);
const loadExternalImagesForCurrentMail = ref(false); const loadRemoteContentForCurrentMail = ref(false);
const shouldLoadExternalImages = computed(() => autoLoadExternalImages.value || loadExternalImagesForCurrentMail.value); const shouldLoadRemoteContent = computed(() => autoLoadRemoteContent.value || loadRemoteContentForCurrentMail.value);
const mailHtmlContent = computed(() => applyExternalImagePolicy(props.mail.message, shouldLoadExternalImages.value)); const mailHtmlContent = computed(() => applyRemoteContentPolicy(props.mail.message, shouldLoadRemoteContent.value));
watch(() => props.mail.id, () => { watch(() => props.mail.id, () => {
loadExternalImagesForCurrentMail.value = false; loadRemoteContentForCurrentMail.value = false;
}); });
const handleDelete = () => { const handleDelete = () => {
@@ -158,9 +158,9 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('fullscreen') }} {{ t('fullscreen') }}
</n-button> </n-button>
<n-button v-if="!showTextMail && !shouldLoadExternalImages" size="small" tertiary type="info" <n-button v-if="!showTextMail && !shouldLoadRemoteContent" size="small" tertiary type="info"
@click="loadExternalImagesForCurrentMail = true"> @click="loadRemoteContentForCurrentMail = true">
{{ t('loadExternalImages') }} {{ t('loadRemoteContent') }}
</n-button> </n-button>
</n-space> </n-space>
@@ -180,9 +180,9 @@ const handleSaveToS3 = async (filename, blob) => {
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false" <n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 100vh;"> style="height: 100vh;">
<n-drawer-content :title="mail.subject" closable> <n-drawer-content :title="mail.subject" closable>
<n-space v-if="!showTextMail && !shouldLoadExternalImages" class="fullscreen-actions"> <n-space v-if="!showTextMail && !shouldLoadRemoteContent" class="fullscreen-actions">
<n-button size="small" tertiary type="info" @click="loadExternalImagesForCurrentMail = true"> <n-button size="small" tertiary type="info" @click="loadRemoteContentForCurrentMail = true">
{{ t('loadExternalImages') }} {{ t('loadRemoteContent') }}
</n-button> </n-button>
</n-space> </n-space>
+2 -2
View File
@@ -178,7 +178,7 @@ export const deMessages = {
"views.admin.SenderAccess.disable": "Deaktivieren", "views.admin.SenderAccess.disable": "Deaktivieren",
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert", "views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
"views.common.Appearance.preferShowTextMail": "Text-Mail standardmäßig anzeigen", "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.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.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", "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_address_required": "Eine Weiterleitungsadresse ist erforderlich",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com", "views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Vollbild", "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.common.Login.generateName": "Zufälligen Namen erzeugen",
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste", "views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
"views.common.Appearance.globalTabplacement": "Globale Tab-Position", "views.common.Appearance.globalTabplacement": "Globale Tab-Position",
+2 -2
View File
@@ -178,7 +178,7 @@ export const esMessages = {
"views.admin.SenderAccess.disable": "Deshabilitar", "views.admin.SenderAccess.disable": "Deshabilitar",
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada", "views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
"views.common.Appearance.preferShowTextMail": "Mostrar correo en texto por defecto", "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.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.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", "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_address_required": "La dirección de reenvío es obligatoria",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com", "views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Pantalla completa", "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.common.Login.generateName": "Generar nombre aleatorio",
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos", "views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
"views.common.Appearance.globalTabplacement": "Posición global de pestañas", "views.common.Appearance.globalTabplacement": "Posición global de pestañas",
+2 -2
View File
@@ -178,7 +178,7 @@ export const jaMessages = {
"views.admin.SenderAccess.disable": "無効化", "views.admin.SenderAccess.disable": "無効化",
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化", "views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
"views.common.Appearance.preferShowTextMail": "既定でテキストメールを表示", "views.common.Appearance.preferShowTextMail": "既定でテキストメールを表示",
"views.common.Appearance.autoLoadExternalImages": "外部画像を自動で読み込む", "views.common.Appearance.autoLoadRemoteContent": "リモートコンテンツを自動で読み込む",
"views.admin.AccountSettings.domain_list": "ドメイン一覧(任意)", "views.admin.AccountSettings.domain_list": "ドメイン一覧(任意)",
"views.admin.AccountSettings.source_patterns_tip": "ドメイン一覧は受信先アドレスで、送信元正規表現は送信者アドレスでフィルタします。転送には両方の条件を満たす必要があります(AND)。どちらかを空欄にするとその条件は無視されます。", "views.admin.AccountSettings.source_patterns_tip": "ドメイン一覧は受信先アドレスで、送信元正規表現は送信者アドレスでフィルタします。転送には両方の条件を満たす必要があります(AND)。どちらかを空欄にするとその条件は無視されます。",
"views.admin.UserManagement.domains": "ドメイン", "views.admin.UserManagement.domains": "ドメイン",
@@ -238,7 +238,7 @@ export const jaMessages = {
"views.admin.AccountSettings.forward_address_required": "転送先アドレスは必須です", "views.admin.AccountSettings.forward_address_required": "転送先アドレスは必須です",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com", "views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "全画面", "components.MailContentRenderer.fullscreen": "全画面",
"components.MailContentRenderer.loadExternalImages": "外部画像を読み込む", "components.MailContentRenderer.loadRemoteContent": "リモートコンテンツを読み込む",
"views.common.Login.generateName": "ランダム名を生成", "views.common.Login.generateName": "ランダム名を生成",
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧", "views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
"views.common.Appearance.globalTabplacement": "全体タブ位置", "views.common.Appearance.globalTabplacement": "全体タブ位置",
+2 -2
View File
@@ -178,7 +178,7 @@ export const ptBRMessages = {
"views.admin.SenderAccess.disable": "Desativar", "views.admin.SenderAccess.disable": "Desativar",
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada", "views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
"views.common.Appearance.preferShowTextMail": "Exibir e-mail em texto por padrão", "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.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.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", "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_address_required": "O endereço de encaminhamento é obrigatório",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com", "views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Tela cheia", "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.common.Login.generateName": "Gerar nome aleatório",
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails", "views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
"views.common.Appearance.globalTabplacement": "Posição global das abas", "views.common.Appearance.globalTabplacement": "Posição global das abas",
+6 -6
View File
@@ -186,9 +186,9 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen", "en": "Fullscreen",
"zh": "全屏" "zh": "全屏"
}, },
"loadExternalImages": { "loadRemoteContent": {
"en": "Load External Images", "en": "Load Remote Content",
"zh": "加载外部图片" "zh": "加载远程内容"
}, },
"reply": { "reply": {
"en": "Reply", "en": "Reply",
@@ -2118,9 +2118,9 @@ export const MESSAGE_REGISTRY = {
"en": "Display text Mail by default", "en": "Display text Mail by default",
"zh": "默认以文本显示邮件" "zh": "默认以文本显示邮件"
}, },
"autoLoadExternalImages": { "autoLoadRemoteContent": {
"en": "Automatically load external images", "en": "Automatically load remote content",
"zh": "自动加载外部图片" "zh": "自动加载远程内容"
}, },
"right": { "right": {
"en": "right", "en": "right",
+2 -2
View File
@@ -90,7 +90,7 @@ export const useGlobalState = createGlobalState(
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25); const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false); const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false); const preferShowTextMail = useStorage('preferShowTextMail', false);
const autoLoadExternalImages = useStorage('autoLoadExternalImages', true); const autoLoadRemoteContent = useStorage('autoLoadRemoteContent', false);
const userJwt = useStorage('userJwt', ''); const userJwt = useStorage('userJwt', '');
const preferredLocale = useStorage('preferredLocale', ''); const preferredLocale = useStorage('preferredLocale', '');
const userTab = useSessionStorage('userTab', 'address_management'); const userTab = useSessionStorage('userTab', 'address_management');
@@ -163,7 +163,7 @@ export const useGlobalState = createGlobalState(
mailboxSplitSize, mailboxSplitSize,
useIframeShowMail, useIframeShowMail,
preferShowTextMail, preferShowTextMail,
autoLoadExternalImages, autoLoadRemoteContent,
userJwt, userJwt,
preferredLocale, preferredLocale,
userTab, userTab,
-57
View File
@@ -1,57 +0,0 @@
const EXTERNAL_IMAGE_PLACEHOLDER = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80">
<rect width="320" height="80" rx="8" fill="#f3f4f6"/>
<text x="160" y="45" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#6b7280">External image blocked</text>
</svg>
`)}`;
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 = /<!doctype|<html[\s>]/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 `<!doctype html>\n${doc.documentElement.outerHTML}`;
}
return doc.body.innerHTML;
};
+66
View File
@@ -0,0 +1,66 @@
const REMOTE_CONTENT_PLACEHOLDER = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80">
<rect width="320" height="80" rx="8" fill="#f3f4f6"/>
<text x="160" y="45" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#6b7280">Remote content blocked</text>
</svg>
`)}`;
const REMOTE_URL_PATTERN = /^(?:https?:)?\/\//i;
const DOCUMENT_SHELL_PATTERN = /<!doctype|<html[\s>]/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 `<!doctype html>\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);
};
+3 -3
View File
@@ -12,7 +12,7 @@ const props = defineProps({
const { const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval, mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadExternalImages globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteContent
} = useGlobalState() } = useGlobalState()
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -43,8 +43,8 @@ const { t } = useScopedI18n('views.common.Appearance')
<n-form-item-row :label="t('useIframeShowMail')"> <n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" /> <n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('autoLoadExternalImages')"> <n-form-item-row :label="t('autoLoadRemoteContent')">
<n-switch v-model:value="autoLoadExternalImages" :round="false" /> <n-switch v-model:value="autoLoadRemoteContent" :round="false" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('useUTCDate')"> <n-form-item-row :label="t('useUTCDate')">
<n-switch v-model:value="useUTCDate" :round="false" /> <n-switch v-model:value="useUTCDate" :round="false" />