feat(mail): HTML 邮件 iframe 安全预览(默认禁脚本+JS 提示启用),支持查看源代码与原文
This commit is contained in:
1 parent
670bb7bc76
commit
ee6cd18c50
2 files changed
+190
-1
No files matched your search
@@ -140,6 +140,47 @@ export class JmapClient {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 拉取邮件 HTML 原文(供 iframe 渲染/查看源代码),优先 htmlBody 的 text/html part */
|
||||
async fetchEmailHtml(email: JmapEmail): Promise<string> {
|
||||
for (const p of email.htmlBody || []) {
|
||||
if (!p.blobId) continue
|
||||
try {
|
||||
const payload = await this.call<{ list?: Array<{ id: string; data?: string; 'data:asText'?: string }> }>(
|
||||
'Blob/get',
|
||||
{ accountId: this.accountId, ids: [p.blobId] },
|
||||
['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:blob'],
|
||||
)
|
||||
const item = payload.list?.[0]
|
||||
if (!item) continue
|
||||
let text = item['data:asText']
|
||||
if (text == null) {
|
||||
if (!item.data) continue
|
||||
text = atob(item.data)
|
||||
}
|
||||
return text
|
||||
} catch {
|
||||
/* 尝试下一个 part */
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 拉取邮件 RFC 5322 原文(含全部头部),通过 Email/get 的 raw 属性(base64) */
|
||||
async fetchEmailRaw(emailId: string): Promise<string> {
|
||||
const payload = await this.call<{ list?: Array<{ raw?: string }> }>('Email/get', {
|
||||
accountId: this.accountId,
|
||||
ids: [emailId],
|
||||
properties: ['raw'],
|
||||
})
|
||||
const raw = payload.list?.[0]?.raw
|
||||
if (raw == null) return ''
|
||||
try {
|
||||
return atob(raw)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 Email/set 响应:存在 notUpdated/notDestroyed 即抛错(call() 不识别此类业务失败) */
|
||||
private checkEmailSetError(payload: Record<string, unknown>): void {
|
||||
const notUpdated = (payload as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated
|
||||
|
||||
+149
-1
@@ -359,6 +359,14 @@ function toggleSelectAll(): void {
|
||||
|
||||
async function selectEmail(e: JmapEmail): Promise<void> {
|
||||
currentEmail.value = e
|
||||
// 重置阅读状态
|
||||
readMode.value = 'html'
|
||||
enableScripts.value = false
|
||||
emailHtml.value = ''
|
||||
emailHtmlReady.value = false
|
||||
hasScript.value = false
|
||||
emailRaw.value = ''
|
||||
rawLoaded.value = false
|
||||
// Stalwart v0.11 正文在 Blob:先用 bodyValues(若有),否则异步拉取
|
||||
emailBodyText.value = plainBody(e) || '加载中…'
|
||||
void client.value?.fetchEmailBody(e).then((txt) => {
|
||||
@@ -366,6 +374,17 @@ async function selectEmail(e: JmapEmail): Promise<void> {
|
||||
}).catch(() => {
|
||||
if (currentEmail.value?.id === e.id) emailBodyText.value = '(正文加载失败)'
|
||||
})
|
||||
// 有 HTML part 时异步拉取 HTML 原文,供 iframe 安全预览/查看源代码,并检测脚本
|
||||
if ((e.htmlBody || []).length) {
|
||||
void client.value?.fetchEmailHtml(e).then((html) => {
|
||||
if (currentEmail.value?.id !== e.id) return
|
||||
emailHtml.value = html
|
||||
emailHtmlReady.value = true
|
||||
hasScript.value = DETECT_JS_RE.test(html)
|
||||
}).catch(() => {
|
||||
if (currentEmail.value?.id === e.id) emailHtmlReady.value = false
|
||||
})
|
||||
}
|
||||
if (!isSeen(e)) {
|
||||
e.keywords = { ...e.keywords, $seen: true }
|
||||
setLocalSeen(e.id, true)
|
||||
@@ -669,6 +688,38 @@ async function logoutMail(): Promise<void> {
|
||||
// Stalwart v0.11 的 Email/get 不返回 bodyValues,正文经 Blob/get 异步拉取(见 selectEmail)
|
||||
const emailBodyText = ref('')
|
||||
|
||||
// === 阅读模式:iframe HTML 预览 / 纯文本 / 源代码 / 原文 ===
|
||||
type ReadMode = 'html' | 'text' | 'source' | 'raw'
|
||||
const readMode = ref<ReadMode>('html')
|
||||
const emailHtml = ref('') // HTML 原文(iframe 渲染与查看源代码共用)
|
||||
const emailHtmlReady = ref(false) // HTML 是否已成功拉取
|
||||
const hasScript = ref(false) // HTML 是否包含脚本(<script> 或内联事件)
|
||||
const enableScripts = ref(false) // 用户是否启用脚本
|
||||
const emailRaw = ref('') // RFC 5322 原文
|
||||
const rawLoading = ref(false)
|
||||
const rawLoaded = ref(false)
|
||||
|
||||
/** 检测邮件 HTML 是否含脚本:<script> 标签或内联事件属性(on*) */
|
||||
const DETECT_JS_RE = /<script[\s>]|\son(?:load|click|error|mouseover|mouseout|mousedown|mouseup|dblclick|submit|change|focus|blur|keydown|keyup|input|scroll|resize|wheel|contextmenu|pointerdown|pointerup|touchstart|touchend)\s*=/i
|
||||
|
||||
/** iframe sandbox:默认禁脚本;用户启用后仅放行 allow-scripts(不加 allow-same-origin,防逃逸父页面) */
|
||||
const iframeSandbox = computed(() => (enableScripts.value ? 'allow-scripts' : ''))
|
||||
|
||||
/** 切换到原文模式时懒加载 RFC822 原文 */
|
||||
watch(readMode, async (mode) => {
|
||||
if (mode === 'raw' && !rawLoaded.value && currentEmail.value && client.value) {
|
||||
rawLoading.value = true
|
||||
try {
|
||||
emailRaw.value = (await client.value.fetchEmailRaw(currentEmail.value.id)) || '(无法获取原文)'
|
||||
rawLoaded.value = true
|
||||
} catch {
|
||||
emailRaw.value = '(原文获取失败)'
|
||||
} finally {
|
||||
rawLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function formatSender(e: JmapEmail): string {
|
||||
const from = e.from?.[0]
|
||||
if (!from) return '未知发件人'
|
||||
@@ -904,6 +955,12 @@ function isSelected(id: string): boolean {
|
||||
<el-button size="small" :icon="RefreshLeft" @click="replyTo(currentEmail)">回复</el-button>
|
||||
<el-button size="small" :icon="Promotion" @click="forwardTo(currentEmail)">转发</el-button>
|
||||
<div class="spacer" />
|
||||
<el-radio-group v-model="readMode" size="small">
|
||||
<el-radio-button value="html">网页</el-radio-button>
|
||||
<el-radio-button value="text">纯文本</el-radio-button>
|
||||
<el-radio-button value="source">源代码</el-radio-button>
|
||||
<el-radio-button value="raw">原文</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button size="small" text type="danger" :icon="Delete" @click="deleteCurrent">删除</el-button>
|
||||
</div>
|
||||
<div class="read-subject">{{ currentEmail.subject || '(无主题)' }}</div>
|
||||
@@ -913,7 +970,41 @@ function isSelected(id: string): boolean {
|
||||
<div class="read-line"><span class="meta-label">时间</span>{{ formatDate(currentEmail.receivedAt || currentEmail.date) }}</div>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="read-body">{{ emailBodyText }}</div>
|
||||
|
||||
<!-- 正文区:网页(iframe)/ 纯文本 / 源代码 / 原文 -->
|
||||
<div class="read-content">
|
||||
<!-- 网页模式:iframe 渲染,默认禁脚本 -->
|
||||
<template v-if="readMode === 'html'">
|
||||
<template v-if="emailHtmlReady">
|
||||
<div class="html-notice">
|
||||
<el-icon class="notice-icon"><Warning /></el-icon>
|
||||
<span>当前以 iframe 安全模式预览(脚本已禁用)</span>
|
||||
<el-button v-if="hasScript && !enableScripts" size="small" type="primary" @click="enableScripts = true">
|
||||
此邮件包含脚本,点击启用
|
||||
</el-button>
|
||||
<span v-else-if="hasScript && enableScripts" class="notice-js-on">脚本已启用,请确认发件人可信</span>
|
||||
</div>
|
||||
<iframe
|
||||
class="html-frame"
|
||||
:sandbox="iframeSandbox"
|
||||
:srcdoc="emailHtml"
|
||||
title="邮件内容预览"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="read-body">{{ emailBodyText }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 纯文本模式 -->
|
||||
<div v-else-if="readMode === 'text'" class="read-body">{{ emailBodyText }}</div>
|
||||
|
||||
<!-- 源代码模式 -->
|
||||
<pre v-else-if="readMode === 'source'" class="read-source">{{ emailHtmlReady ? emailHtml : (emailBodyText || '(无 HTML 正文)') }}</pre>
|
||||
|
||||
<!-- 原文模式:RFC 5322 -->
|
||||
<div v-else-if="readMode === 'raw'" v-loading="rawLoading" class="read-raw-wrap">
|
||||
<pre class="read-source">{{ emailRaw }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="read-empty">
|
||||
<el-empty description="选择一封邮件阅读" :image-size="120" />
|
||||
@@ -1483,6 +1574,63 @@ html.dark .mail-login-wrap {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
/* HTML 邮件 iframe 预览 */
|
||||
.read-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.html-notice {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 16px 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.html-notice .notice-icon {
|
||||
color: var(--el-color-warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.html-notice .notice-js-on {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.html-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
html.dark .html-frame {
|
||||
background: #1d1e1f;
|
||||
}
|
||||
/* 源代码 / 原文 */
|
||||
.read-source {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px 20px 20px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: var(--el-font-family-mono, Consolas, 'Courier New', monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.read-raw-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.read-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user