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:
1 parent
feb187ba23
commit
3e0af7596e
3 files changed
+58
-12
No files matched your search
+4
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user