fix(mail): 邮件正文经 Blob/get 异步拉取(Stalwart v0.11 不返回 bodyValues)

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 清空正文
This commit is contained in:
TermiNexus committed 2026-08-11 01:22:25 +08:00
1 parent feb187ba23
commit 3e0af7596e
3 files changed
+58 -12

No files matched your search

+4 -3
View File
@@ -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<void> => post<void>('/API/Mail/ChangePassword', { NewPassword }),
/** 修改邮箱密码(需先验证当前密码) */
changePassword: (OldPassword: string, NewPassword: string): Promise<void> =>
post<void>('/API/Mail/ChangePassword', { OldPassword, NewPassword }),
/** 邮箱登录:校验密码并换取会话 token(密码仅此一次传输,后端存内存;401 为密码错误,不触发系统登出) */
login: (Password: string): Promise<MailLoginResult> => post<MailLoginResult>('/API/Mail/Login', { Password }, { silent: true }),
@@ -76,7 +77,7 @@ export const mailApi = {
logout: (Token: string): Promise<void> => post<void>('/API/Mail/Logout', { Token }),
/** JMAP 收发代理(携带会话 token,不再传输明文密码;401 为邮箱会话失效,不触发系统登出) */
jmap: (data: { Token: string; MethodCalls: Array<[string, Record<string, unknown>, string]>; AccountId?: string }): Promise<JmapProxyResult> =>
jmap: (data: { Token: string; MethodCalls: Array<[string, Record<string, unknown>, string]>; AccountId?: string; Using?: string[] }): Promise<JmapProxyResult> =>
post<JmapProxyResult>('/API/Mail/Jmap', data, { silent: true }),
/** 我的联系人(Search 为空返回全部;发件成功后后端自动存储收件人) */
+31 -3
View File
@@ -75,12 +75,13 @@ export class JmapClient {
}
/** 批量调用 JMAP 方法(经后端代理,携带会话 token,密码不落前端) */
async request(methodCalls: Array<[string, Record<string, unknown>, string]>): Promise<JmapCallResult> {
async request(methodCalls: Array<[string, Record<string, unknown>, string]>, using?: string[]): Promise<JmapCallResult> {
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<T>(method: string, args: Record<string, unknown>): Promise<T> {
const result = await this.request([[method, args, 'c0']])
private async call<T>(method: string, args: Record<string, unknown>, using?: string[]): Promise<T> {
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<string> {
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<string, unknown>): void {
const notUpdated = (payload as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated
+23 -6
View File
@@ -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<void> {
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<void> {
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<void> {
async function submitChangePassword(): Promise<void> {
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<void> {
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<void> {
// === 渲染辅助 ===
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 {
<!-- 改密弹窗 -->
<el-dialog v-model="changePwdVisible" title="修改邮箱密码" width="400px">
<el-form label-width="80px">
<el-form-item label="当前密码">
<el-input v-model="changePwdForm.OldPassword" type="password" show-password placeholder="请输入当前邮箱密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="changePwdForm.NewPassword" type="password" show-password placeholder="8~64 位,含字母和数字" />
</el-form-item>