fix(mail): 全部邮件排除垃圾箱、Email/set 结果校验与已读本地持久化

- 全部邮件查询用 inMailboxOtherThan 排除垃圾箱/垃圾邮件,删除后不再出现 - Email/set 校验 notUpdated/notDestroyed,已读/删除失败不再静默 - 已读本地覆盖表过渡 Stalwart v0.11 keywords 写入 bug,刷新保持已读
This commit is contained in:
TermiNexus committed 2026-08-08 16:52:38 +08:00
1 parent 162f713b2e
commit 0900bfb7b5
2 files changed
+77 -27

No files matched your search

+35 -15
View File
@@ -106,21 +106,42 @@ export class JmapClient {
return payload as unknown as T
}
/** 校验 Email/set 响应:存在 notUpdated/notDestroyed 即抛错(call() 不识别此类业务失败) */
private checkEmailSetError(payload: Record<string, unknown>): void {
const notUpdated = (payload as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated
const notDestroyed = (payload as { notDestroyed?: Record<string, { type?: string; description?: string }> }).notDestroyed
const err = notUpdated ? Object.values(notUpdated)[0] : notDestroyed ? Object.values(notDestroyed)[0] : undefined
if (err) throw new Error(err.description || '更新邮件状态失败')
}
/** 执行 Email/set update 并校验结果 */
private async emailSetUpdate(update: Record<string, unknown>): Promise<void> {
const result = await this.request([['Email/set', { accountId: this.accountId, update }, 'c0']])
const [, payload] = result.methodResponses[0] || []
this.checkEmailSetError((payload || {}) as Record<string, unknown>)
}
/** 获取全部邮箱 */
async getMailboxes(): Promise<JmapMailbox[]> {
const payload = await this.call<{ list: JmapMailbox[] }>('Mailbox/get', { accountId: this.accountId, ids: null })
return payload.list || []
}
/** 查询邮箱内邮件 id(mailboxId 为空时查全部;按接收时间倒序) */
/** 查询邮箱内邮件 id(mailboxId 为空时查全部——排除垃圾箱/垃圾邮件;按接收时间倒序) */
async queryEmailIds(mailboxId: string | null = null, limit = 50): Promise<string[]> {
// filter 为空时不传该字段:Stalwart v0.11 对空 filter {} 会返回 serverFail(全部邮件视图 502)
const args: Record<string, unknown> = {
accountId: this.accountId,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}
if (mailboxId) args.filter = { inMailbox: mailboxId }
if (mailboxId) {
args.filter = { inMailbox: mailboxId }
} else {
// 全部邮件:排除垃圾箱/垃圾邮件(删除=移入垃圾箱,不应再出现在全部邮件中)
const mbs = await this.getMailboxes()
const exclude = mbs.filter((m) => m.role === 'trash' || m.role === 'junk').map((m) => m.id)
if (exclude.length) args.filter = { inMailboxOtherThan: exclude }
}
const payload = await this.call<{ ids: string[] }>('Email/query', args)
return payload.ids || []
}
@@ -140,19 +161,14 @@ export class JmapClient {
/** 标记已读/未读 */
async setSeen(emailId: string, seen: boolean): Promise<void> {
const keywords = seen ? { $seen: true } : { $seen: false }
await this.call<unknown>('Email/set', {
accountId: this.accountId,
update: { [emailId]: { keywords } },
})
await this.emailSetUpdate({ [emailId]: { keywords: seen ? { $seen: true } : { $seen: false } } })
}
/** 删除邮件 */
async deleteEmail(emailId: string): Promise<void> {
await this.call<unknown>('Email/set', {
accountId: this.accountId,
destroy: [emailId],
})
const result = await this.request([['Email/set', { accountId: this.accountId, destroy: [emailId] }, 'c0']])
const [, payload] = result.methodResponses[0] || []
this.checkEmailSetError((payload || {}) as Record<string, unknown>)
}
/** 批量标记已读/未读 */
@@ -162,7 +178,7 @@ export class JmapClient {
for (const id of emailIds) {
update[id] = { keywords: seen ? { $seen: true } : { $seen: false } }
}
await this.call<unknown>('Email/set', { accountId: this.accountId, update })
await this.emailSetUpdate(update)
}
/** 批量删除:传入 fromMailboxId 且有垃圾箱时移入垃圾箱;否则直接销毁 */
@@ -171,14 +187,18 @@ export class JmapClient {
const mailboxes = await this.getMailboxes()
const trash = mailboxes.find((m) => m.role === 'trash')
if (!fromMailboxId || !trash) {
await this.call<unknown>('Email/set', { accountId: this.accountId, destroy: emailIds })
const result = await this.request([['Email/set', { accountId: this.accountId, destroy: emailIds }, 'c0']])
const [, payload] = result.methodResponses[0] || []
this.checkEmailSetError((payload || {}) as Record<string, unknown>)
return
}
const update: Record<string, unknown> = {}
for (const id of emailIds) {
update[id] = { mailboxIds: { [trash.id]: true, [fromMailboxId]: false } }
}
await this.call<unknown>('Email/set', { accountId: this.accountId, update })
const result = await this.request([['Email/set', { accountId: this.accountId, update }, 'c0']])
const [, payload] = result.methodResponses[0] || []
this.checkEmailSetError((payload || {}) as Record<string, unknown>)
}
/** 获取发信身份(Identity) */
+42 -12
View File
@@ -62,6 +62,32 @@ const changePwdForm = ref({ NewPassword: '', ConfirmPassword: '' })
const myAddress = computed(() => mailbox.value?.MailboxName || '')
// === 本地已读覆盖 ===
// Stalwart v0.11 存在 JMAP keywords 写入已知 bug(GitHub #1139):setSeen 返回成功但已读随机回退未读,
// 已读状态无法真正落库。此表为过渡方案:本地立即生效并持久化,刷新后仍保持已读;
// 同时仍向服务器发送 setSeen,升级 Stalwart 至 v0.15+(commit 93a8265 修复)后自动真正落库。
const SEEN_STORE_KEY = 'gerp_mail_seen'
const localSeen = ref<Record<string, boolean>>(loadLocalSeen())
function loadLocalSeen(): Record<string, boolean> {
try {
return JSON.parse(localStorage.getItem(SEEN_STORE_KEY) || '{}') as Record<string, boolean>
} catch {
return {}
}
}
function setLocalSeen(id: string, seen: boolean): void {
localSeen.value = { ...localSeen.value, [id]: seen }
localStorage.setItem(SEEN_STORE_KEY, JSON.stringify(localSeen.value))
}
/** 已读判定:本地覆盖优先,其次服务器 keywords */
function isSeen(e: JmapEmail): boolean {
if (Object.prototype.hasOwnProperty.call(localSeen.value, e.id)) return localSeen.value[e.id]
return !!e.keywords?.$seen
}
// === 邮箱会话持久化 ===
// 登录成功后将会话 token 存入 localStorage,刷新/重开页面自动恢复,无需重复输入密码。
// token 由后端签发(24h 有效,存进程内存映射邮箱凭据),失效时自动清除并回到登录页。
@@ -281,9 +307,10 @@ function toggleSelectAll(): void {
async function selectEmail(e: JmapEmail): Promise<void> {
currentEmail.value = e
if (!e.keywords?.$seen) {
if (!isSeen(e)) {
e.keywords = { ...e.keywords, $seen: true }
void client.value?.setSeen(e.id, true)
setLocalSeen(e.id, true)
void client.value?.setSeen(e.id, true).catch(() => undefined)
}
}
@@ -291,15 +318,18 @@ async function markSelected(seen: boolean): Promise<void> {
if (!selectedIds.value.length || !client.value) return
try {
await client.value.setSeenBatch(selectedIds.value, seen)
for (const e of emails.value) {
if (selectedIds.value.includes(e.id)) e.keywords = { ...e.keywords, $seen: seen }
}
selectedIds.value = []
ElMessage.success(seen ? '已标记为已读' : '已标记为未读')
} catch (e) {
if (e instanceof JmapAuthError) return
ElMessage.error((e as Error).message || '操作失败')
// Stalwart v0.11 keywords 写入 bug:服务器失败仍本地生效,刷新不丢已读
}
for (const e of emails.value) {
if (selectedIds.value.includes(e.id)) {
e.keywords = { ...e.keywords, $seen: seen }
setLocalSeen(e.id, seen)
}
}
selectedIds.value = []
ElMessage.success(seen ? '已标记为已读' : '已标记为未读')
}
// === 删除 ===
@@ -514,7 +544,7 @@ function smartDate(iso: string): string {
function rowClass(e: JmapEmail): string {
return [
!e.keywords?.$seen ? 'row-unread' : '',
!isSeen(e) ? 'row-unread' : '',
currentEmail.value?.id === e.id ? 'row-active' : '',
].filter(Boolean).join(' ')
}
@@ -627,11 +657,11 @@ function isSelected(id: string): boolean {
</div>
<div class="row-main">
<div class="row-top">
<span class="row-sender" :class="{ 'txt-unread': !e.keywords?.$seen }">{{ senderLabel(e) }}</span>
<span class="row-sender" :class="{ 'txt-unread': !isSeen(e) }">{{ senderLabel(e) }}</span>
<span class="row-date">{{ smartDate(e.receivedAt || e.date) }}</span>
</div>
<div class="row-subject" :class="{ 'txt-unread': !e.keywords?.$seen }">
<span v-if="!e.keywords?.$seen" class="unread-dot" />
<div class="row-subject" :class="{ 'txt-unread': !isSeen(e) }">
<span v-if="!isSeen(e)" class="unread-dot" />
<span class="subject-text">{{ e.subject || '(无主题)' }}</span>
</div>
<div v-if="e.preview" class="row-preview">{{ e.preview }}</div>