From ee6cd18c50fbfe9023e26b1c85a69496c8beffa7 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 13 Aug 2026 01:16:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20HTML=20=E9=82=AE=E4=BB=B6=20ifram?= =?UTF-8?q?e=20=E5=AE=89=E5=85=A8=E9=A2=84=E8=A7=88=EF=BC=88=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=A6=81=E8=84=9A=E6=9C=AC+JS=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=90=AF=E7=94=A8=EF=BC=89=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E6=BA=90=E4=BB=A3=E7=A0=81=E4=B8=8E=E5=8E=9F=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/jmap.ts | 41 ++++++++++ src/views/mail/Mailbox.vue | 150 ++++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/src/utils/jmap.ts b/src/utils/jmap.ts index df13edd..60bad85 100644 --- a/src/utils/jmap.ts +++ b/src/utils/jmap.ts @@ -140,6 +140,47 @@ export class JmapClient { return '' } + /** 拉取邮件 HTML 原文(供 iframe 渲染/查看源代码),优先 htmlBody 的 text/html part */ + async fetchEmailHtml(email: JmapEmail): Promise { + 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 { + 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): void { const notUpdated = (payload as { notUpdated?: Record }).notUpdated diff --git a/src/views/mail/Mailbox.vue b/src/views/mail/Mailbox.vue index f0cde9c..6bd3505 100644 --- a/src/views/mail/Mailbox.vue +++ b/src/views/mail/Mailbox.vue @@ -359,6 +359,14 @@ function toggleSelectAll(): void { async function selectEmail(e: JmapEmail): Promise { 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 { }).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 { // Stalwart v0.11 的 Email/get 不返回 bodyValues,正文经 Blob/get 异步拉取(见 selectEmail) const emailBodyText = ref('') +// === 阅读模式:iframe HTML 预览 / 纯文本 / 源代码 / 原文 === +type ReadMode = 'html' | 'text' | 'source' | 'raw' +const readMode = ref('html') +const emailHtml = ref('') // HTML 原文(iframe 渲染与查看源代码共用) +const emailHtmlReady = ref(false) // HTML 是否已成功拉取 +const hasScript = ref(false) // HTML 是否包含脚本(