fix(imap): 修复 Thunderbird 未读状态丢失(SQLite 并发写 + 客户端 seq 错位)

A. SQLite 并发写失败被吞(高概率根因):
- DSN 追加 _busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL,
  WAL 下读不阻塞写,消除瞬时 SQLITE_BUSY
- UpdateMessagesFlags 不再吞 MarkReadState/MarkFlagged 错误:
  出错记日志并返回 error(客户端收到 NO 会重试)
- Web MarkRead/Delete 同款静默丢错补日志

B. 客户端 seq 视图错位(集成测试复现后修复):
- 真实服务器+脚本客户端测试复现:客户端按日期倒序自编号发
  seq 式 STORE 时,旧排序(id ASC)把最旧邮件标为已读
- 规范排序改为 date DESC, id DESC(最新在前),与主流客户端
  默认视图一致;buildNewMessageUpdate 改用 seqOf 取真实序号
- 新增 UID STORE / 服务器下发 seq STORE / 自编号 seq STORE 集成测试

注:go-imap v1.2.1 存在库内 *conn.silent() 数据竞争(启用推送后
必然触发),集成测试以 //go:build !race 排除,-race 下由单元测试
覆盖推送逻辑
This commit is contained in:
2026-08-19 20:30:06 +08:00
parent 278c16c36a
commit 07ba01a6f6
6 changed files with 236 additions and 16 deletions
+17 -7
View File
@@ -45,14 +45,14 @@ func (b *imapBackend) Updates() <-chan backend.Update {
}
// buildNewMessageUpdate 为一条新投递到 mailbox 的邮件构造 IMAP 更新。
// seq 取该邮件在邮箱中的序号(通知时机在入库之后,取当前列表长度)。
// seq 取该邮件在邮箱中的实际序号(最新在前,通常为 1)。
func buildNewMessageUpdate(stores *store.Stores, userEmail, mailbox string, msg *db.Message) *backend.MessageUpdate {
if stores == nil || msg == nil || userEmail == "" || mailbox == "" {
return nil
}
seq := uint32(1)
if msgs, err := stores.Mails.ListAllByUserAndFolder(msg.UserID, mailbox); err == nil {
seq = uint32(len(msgs))
seq := seqOf(stores, msg.UserID, mailbox, msg.ID)
if seq == 0 {
seq = 1
}
imapMsg := imap.NewMessage(seq, []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchEnvelope})
@@ -751,6 +751,10 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
return err
}
// 记录首个持久化错误:SQLite 忙/锁等瞬时失败必须让客户端感知
// (返回 NO 触发重试),否则已读/星标会静默丢失。
var firstErr error
for i, dbMsg := range dbMessages {
var match bool
if uid {
@@ -770,9 +774,15 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
applyFlag := func(flag string, enabled bool) {
switch flag {
case "\\Seen":
_ = m.stores.Mails.MarkReadState(dbMsg.ID, enabled)
if err := m.stores.Mails.MarkReadState(dbMsg.ID, enabled); err != nil && firstErr == nil {
log.Printf("IMAP: mark read state for msg %d failed: %v", dbMsg.ID, err)
firstErr = err
}
case "\\Flagged":
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, enabled)
if err := m.stores.Mails.MarkFlagged(dbMsg.ID, enabled); err != nil && firstErr == nil {
log.Printf("IMAP: mark flagged for msg %d failed: %v", dbMsg.ID, err)
firstErr = err
}
case "\\Deleted":
if enabled {
m.deleted[dbMsg.ID] = true
@@ -807,7 +817,7 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
pushUpdate(m.user.updates, buildFlagsUpdate(m.stores, m.user.email, m.name, fresh, deleted))
}
return nil
return firstErr
}
// CopyMessages copies messages to another mailbox.