diff --git a/src/utils/jmap.ts b/src/utils/jmap.ts index 28c537a..697d8e3 100644 --- a/src/utils/jmap.ts +++ b/src/utils/jmap.ts @@ -106,21 +106,42 @@ export class JmapClient { return payload as unknown as T } + /** 校验 Email/set 响应:存在 notUpdated/notDestroyed 即抛错(call() 不识别此类业务失败) */ + private checkEmailSetError(payload: Record): void { + const notUpdated = (payload as { notUpdated?: Record }).notUpdated + const notDestroyed = (payload as { notDestroyed?: Record }).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): Promise { + const result = await this.request([['Email/set', { accountId: this.accountId, update }, 'c0']]) + const [, payload] = result.methodResponses[0] || [] + this.checkEmailSetError((payload || {}) as Record) + } + /** 获取全部邮箱 */ async getMailboxes(): Promise { 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 { - // filter 为空时不传该字段:Stalwart v0.11 对空 filter {} 会返回 serverFail(全部邮件视图 502) const args: Record = { 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 { - const keywords = seen ? { $seen: true } : { $seen: false } - await this.call('Email/set', { - accountId: this.accountId, - update: { [emailId]: { keywords } }, - }) + await this.emailSetUpdate({ [emailId]: { keywords: seen ? { $seen: true } : { $seen: false } } }) } /** 删除邮件 */ async deleteEmail(emailId: string): Promise { - await this.call('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) } /** 批量标记已读/未读 */ @@ -162,7 +178,7 @@ export class JmapClient { for (const id of emailIds) { update[id] = { keywords: seen ? { $seen: true } : { $seen: false } } } - await this.call('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('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) return } const update: Record = {} for (const id of emailIds) { update[id] = { mailboxIds: { [trash.id]: true, [fromMailboxId]: false } } } - await this.call('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) } /** 获取发信身份(Identity) */ diff --git a/src/views/mail/Mailbox.vue b/src/views/mail/Mailbox.vue index c62e541..92b50b0 100644 --- a/src/views/mail/Mailbox.vue +++ b/src/views/mail/Mailbox.vue @@ -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>(loadLocalSeen()) + +function loadLocalSeen(): Record { + try { + return JSON.parse(localStorage.getItem(SEEN_STORE_KEY) || '{}') as Record + } 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 { 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 { 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 {
- {{ senderLabel(e) }} + {{ senderLabel(e) }} {{ smartDate(e.receivedAt || e.date) }}
-
- +
+ {{ e.subject || '(无主题)' }}
{{ e.preview }}