From 3e0af7596e5cba18fa81ad84fbd8e84cafdb000f Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Tue, 11 Aug 2026 01:22:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(mail):=20=E9=82=AE=E4=BB=B6=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E7=BB=8F=20Blob/get=20=E5=BC=82=E6=AD=A5=E6=8B=89?= =?UTF-8?q?=E5=8F=96=EF=BC=88Stalwart=20v0.11=20=E4=B8=8D=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20bodyValues=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jmap.ts: 新增 fetchEmailBody(Blob/get + atob,HTML 转纯文本),request/call 支持 using 参数(需 urn:ietf:params:jmap:blob capability) mail.ts: jmap 代理数据类型增加 Using;Mailbox.vue: selectEmail 先显示 bodyValues 否则异步拉取正文,emailBodyText 由 computed 改 ref,switchMailbox 清空正文 --- src/api/mail.ts | 7 ++++--- src/utils/jmap.ts | 34 +++++++++++++++++++++++++++++++--- src/views/mail/Mailbox.vue | 29 +++++++++++++++++++++++------ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/api/mail.ts b/src/api/mail.ts index cb200a1..51f4c85 100644 --- a/src/api/mail.ts +++ b/src/api/mail.ts @@ -66,8 +66,9 @@ export const mailApi = { apply: (data: { MailboxName: string; Password: string; QuotaMB?: number }): Promise<{ MailboxName: string; QuotaMB: number }> => post<{ MailboxName: string; QuotaMB: number }>('/API/Mail/Apply', data), - /** 修改邮箱密码 */ - changePassword: (NewPassword: string): Promise => post('/API/Mail/ChangePassword', { NewPassword }), + /** 修改邮箱密码(需先验证当前密码) */ + changePassword: (OldPassword: string, NewPassword: string): Promise => + post('/API/Mail/ChangePassword', { OldPassword, NewPassword }), /** 邮箱登录:校验密码并换取会话 token(密码仅此一次传输,后端存内存;401 为密码错误,不触发系统登出) */ login: (Password: string): Promise => post('/API/Mail/Login', { Password }, { silent: true }), @@ -76,7 +77,7 @@ export const mailApi = { logout: (Token: string): Promise => post('/API/Mail/Logout', { Token }), /** JMAP 收发代理(携带会话 token,不再传输明文密码;401 为邮箱会话失效,不触发系统登出) */ - jmap: (data: { Token: string; MethodCalls: Array<[string, Record, string]>; AccountId?: string }): Promise => + jmap: (data: { Token: string; MethodCalls: Array<[string, Record, string]>; AccountId?: string; Using?: string[] }): Promise => post('/API/Mail/Jmap', data, { silent: true }), /** 我的联系人(Search 为空返回全部;发件成功后后端自动存储收件人) */ diff --git a/src/utils/jmap.ts b/src/utils/jmap.ts index 826c67f..5841306 100644 --- a/src/utils/jmap.ts +++ b/src/utils/jmap.ts @@ -75,12 +75,13 @@ export class JmapClient { } /** 批量调用 JMAP 方法(经后端代理,携带会话 token,密码不落前端) */ - async request(methodCalls: Array<[string, Record, string]>): Promise { + async request(methodCalls: Array<[string, Record, string]>, using?: string[]): Promise { try { const result = await mailApi.jmap({ Token: this.token, MethodCalls: methodCalls, AccountId: this.accountId || undefined, + Using: using, }) this.accountId = result.AccountId return result.Data @@ -96,8 +97,8 @@ export class JmapClient { } /** 提取首个指定方法的响应 */ - private async call(method: string, args: Record): Promise { - const result = await this.request([[method, args, 'c0']]) + private async call(method: string, args: Record, using?: string[]): Promise { + const result = await this.request([[method, args, 'c0']], using) const [, payload] = result.methodResponses[0] || [] if (!payload) throw new Error(`JMAP 方法 ${method} 无响应`) const err = payload as unknown as { type?: string; description?: string; notFound?: unknown[] } @@ -107,6 +108,33 @@ export class JmapClient { return payload as unknown as T } + /** 拉取邮件正文内容——Stalwart v0.11 的 Email/get 不返回 bodyValues, + * 正文通过 Blob/get 按 blobId 获取(base64 编码),HTML 正文提取纯文本 */ + async fetchEmailBody(email: JmapEmail): Promise { + const parts = [...(email.textBody || []), ...(email.htmlBody || [])] + for (const p of parts) { + if (!p.blobId) continue + try { + const payload = await this.call<{ list?: Array<{ id: string; data?: string }> }>( + 'Blob/get', + { accountId: this.accountId, ids: [p.blobId] }, + ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:blob'], + ) + const data = payload.list?.[0]?.data + if (data) { + const text = atob(data) + if ((p.type || '').toLowerCase() === 'text/html') { + return text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() + } + return text + } + } catch { + /* 尝试下一个 part */ + } + } + 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 2bacf3f..d47843c 100644 --- a/src/views/mail/Mailbox.vue +++ b/src/views/mail/Mailbox.vue @@ -69,7 +69,7 @@ const contactSaving = ref(false) // 改密 const changePwdVisible = ref(false) -const changePwdForm = ref({ NewPassword: '', ConfirmPassword: '' }) +const changePwdForm = ref({ OldPassword: '', NewPassword: '', ConfirmPassword: '' }) const myAddress = computed(() => mailbox.value?.MailboxName || '') @@ -318,6 +318,7 @@ async function switchMailbox(id: string): Promise { currentMailboxId.value = id currentPane.value = 'mail' currentEmail.value = null + emailBodyText.value = '' await loadEmails() } @@ -340,6 +341,13 @@ function toggleSelectAll(): void { async function selectEmail(e: JmapEmail): Promise { currentEmail.value = e + // Stalwart v0.11 正文在 Blob:先用 bodyValues(若有),否则异步拉取 + emailBodyText.value = plainBody(e) || '加载中…' + void client.value?.fetchEmailBody(e).then((txt) => { + if (currentEmail.value?.id === e.id) emailBodyText.value = txt || '(无正文内容)' + }).catch(() => { + if (currentEmail.value?.id === e.id) emailBodyText.value = '(正文加载失败)' + }) if (!isSeen(e)) { e.keywords = { ...e.keywords, $seen: true } setLocalSeen(e.id, true) @@ -500,7 +508,8 @@ function openCompose(): void { function plainBody(e: JmapEmail | null): string { if (!e) return '' - // 正文按 bodyValues 引用(JMAP 标准:textBody/htmlBody 仅含 partId,value 在 bodyValues 中) + // 正文按 bodyValues 引用(JMAP 标准:textBody/htmlBody 仅含 partId,value 在 bodyValues 中); + // Stalwart v0.11 不返回 bodyValues(正文在 Blob),此处返回空由 fetchEmailBody 异步补充 const partValue = (parts: JmapEmail['textBody']): string => { for (const p of parts || []) { if (p.partId && e.bodyValues?.[p.partId]?.value != null) return e.bodyValues[p.partId].value as string @@ -511,7 +520,7 @@ function plainBody(e: JmapEmail | null): string { if (text) return text const html = partValue(e.htmlBody) if (html) return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() - return '(无正文内容)' + return '' } function buildQuote(e: JmapEmail): string { @@ -595,6 +604,10 @@ async function submitCompose(): Promise { async function submitChangePassword(): Promise { const f = changePwdForm.value + if (!f.OldPassword) { + ElMessage.warning('请输入当前邮箱密码') + return + } if (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(f.NewPassword)) { ElMessage.warning('新密码需 8~64 位且同时包含字母和数字') return @@ -604,10 +617,10 @@ async function submitChangePassword(): Promise { return } try { - await mailApi.changePassword(f.NewPassword) + await mailApi.changePassword(f.OldPassword, f.NewPassword) ElMessage.success('密码修改成功,请重新登录邮箱') changePwdVisible.value = false - changePwdForm.value = { NewPassword: '', ConfirmPassword: '' } + changePwdForm.value = { OldPassword: '', NewPassword: '', ConfirmPassword: '' } clearMailSession() client.value = null mailboxes.value = [] @@ -635,7 +648,8 @@ async function logoutMail(): Promise { // === 渲染辅助 === -const emailBodyText = computed(() => plainBody(currentEmail.value)) +// Stalwart v0.11 的 Email/get 不返回 bodyValues,正文经 Blob/get 异步拉取(见 selectEmail) +const emailBodyText = ref('') function formatSender(e: JmapEmail): string { const from = e.from?.[0] @@ -935,6 +949,9 @@ function isSelected(id: string): boolean { + + +