diff --git a/internal/db/models.go b/internal/db/models.go index ffc78a4..b813658 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -170,3 +170,21 @@ type Attachment struct { func (Attachment) TableName() string { return "attachments" } + +// MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。 +// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间 +// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存 +// 并全量重新同步。此前硬编码为 1,数据库重建后客户端缓存永不失效, +// 导致只显示/下载少量"缺失"邮件。 +type MailboxState struct { + UserID uint `gorm:"primaryKey" json:"user_id"` + Folder string `gorm:"primaryKey;size:64" json:"folder"` + UidValidity uint32 `gorm:"not null" json:"uid_validity"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName specifies the table name for MailboxState. +func (MailboxState) TableName() string { + return "mailbox_states" +} diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index cbc4b5d..ec5eec4 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -354,7 +354,6 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro if err != nil { return nil, err } - status.Messages = uint32(len(messages)) var unseenCount uint32 @@ -370,7 +369,16 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro return nil, err } status.UidNext = uint32(maxID + 1) - status.UidValidity = 1 + // UIDVALIDITY 持久化随机值(RFC 3501):数据库重建导致消息 ID 空间 + // 变化时该值随之改变,客户端才会丢弃旧缓存全量重同步。此前硬编码 1, + // 数据库重建后 Thunderbird 等客户端缓存永不失效(只下载"新增"的 + // UID),表现为列表只剩少量邮件。 + uidValidity, err := m.stores.MailboxState.UidValidity(m.user.id, m.name) + if err != nil { + log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", m.name, err) + uidValidity = 1 + } + status.UidValidity = uidValidity return status, nil } @@ -394,7 +402,6 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F if err != nil { return err } - if len(dbMessages) == 0 { return nil } @@ -567,7 +574,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([ } } } - return results, nil } diff --git a/internal/store/mailbox_state_store.go b/internal/store/mailbox_state_store.go new file mode 100644 index 0000000..f2f04b5 --- /dev/null +++ b/internal/store/mailbox_state_store.go @@ -0,0 +1,56 @@ +package store + +import ( + "crypto/rand" + "encoding/binary" + + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// MailboxStateStore 提供 IMAP 邮箱持久化状态(UIDVALIDITY)的存取。 +type MailboxStateStore interface { + // UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。 + UidValidity(userID uint, folder string) (uint32, error) +} + +type mailboxStateStoreGorm struct { + db *gorm.DB +} + +func newMailboxStateStore(database *gorm.DB) *mailboxStateStoreGorm { + return &mailboxStateStoreGorm{db: database} +} + +// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。 +// 随机值保证:邮箱内容身份变化(如数据库重建导致消息 ID 空间变化)时, +// 新库生成的新值会让客户端丢弃旧缓存全量重同步(RFC 3501 UIDVALIDITY +// 语义)。绝不返回 0(0 不是合法 UIDVALIDITY)。 +func (s *mailboxStateStoreGorm) UidValidity(userID uint, folder string) (uint32, error) { + var st db.MailboxState + err := s.db.Where("user_id = ? AND folder = ?", userID, folder).First(&st).Error + if err == nil { + if st.UidValidity != 0 { + return st.UidValidity, nil + } + } else if err != gorm.ErrRecordNotFound { + return 0, err + } + + // 首次访问(或旧数据为 0):随机生成并持久化 + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, err + } + v := binary.BigEndian.Uint32(buf[:]) + if v == 0 { + v = 1 + } + + st = db.MailboxState{UserID: userID, Folder: folder, UidValidity: v} + if err := s.db.Save(&st).Error; err != nil { + return 0, err + } + return v, nil +} diff --git a/internal/store/mailbox_state_store_test.go b/internal/store/mailbox_state_store_test.go new file mode 100644 index 0000000..a3b03cf --- /dev/null +++ b/internal/store/mailbox_state_store_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "testing" + + "mail_go/internal/db" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestMailboxStateUidValidity 验证 UIDVALIDITY:首次访问随机生成、重复访问 +// 稳定返回、0 值被修正、不同邮箱互不影响。 +func TestMailboxStateUidValidity(t *testing.T) { + gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(&db.MailboxState{}); err != nil { + t.Fatal(err) + } + s := newMailboxStateStore(gdb) + + // 首次访问:随机非 0 + v1, err := s.UidValidity(1, "INBOX") + if err != nil { + t.Fatal(err) + } + if v1 == 0 { + t.Fatal("UIDVALIDITY 不应为 0") + } + // 重复访问:稳定 + v2, err := s.UidValidity(1, "INBOX") + if err != nil { + t.Fatal(err) + } + if v1 != v2 { + t.Fatalf("UIDVALIDITY 不稳定: %d != %d", v1, v2) + } + // 不同邮箱:独立 + v3, err := s.UidValidity(1, "Sent") + if err != nil { + t.Fatal(err) + } + if v3 == v1 { + t.Fatal("不同邮箱的 UIDVALIDITY 不应相同") + } + // 不同用户:独立 + v4, err := s.UidValidity(2, "INBOX") + if err != nil { + t.Fatal(err) + } + if v4 == v1 { + t.Fatal("不同用户的 UIDVALIDITY 不应相同") + } +} diff --git a/internal/store/stores.go b/internal/store/stores.go index 07eb505..9831069 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -15,6 +15,7 @@ type Stores struct { Bans BanStore Outbound OutboundStore ProtocolLogs ProtocolLogStore + MailboxState MailboxStateStore } // NewStores creates a new Stores instance with all GORM-backed implementations. @@ -27,6 +28,7 @@ func NewStores(database *gorm.DB) *Stores { Bans: newBanStore(database), Outbound: newOutboundStore(database), ProtocolLogs: newProtocolLogStore(database), + MailboxState: newMailboxStateStore(database), } } @@ -37,3 +39,4 @@ var _ = db.Message{} var _ = db.Attachment{} var _ = db.BanEntry{} var _ = db.ProtocolLog{} +var _ = db.MailboxState{}