Files
mailgo/internal/store/mail_store.go
T
kevin 17d8dc1567 fix(imap): 序号按到达顺序(id ASC)分配,INTERNALDATE 使用到达时间
- ListAllByUserAndFolder 由 date DESC 改为 id ASC:新邮件永远追加到末尾
  (seq = EXISTS 数),与 Dovecot/Courier 一致;带旧 Date 头的新邮件不再
  插入中间导致既有邮件序号位移,seq 增量同步客户端不会漏收/标错
- INTERNALDATE 改用 CreatedAt(服务器接收时间, RFC 3501 §2.3.4),
  零值降级用 Date 头;不再返回可被伪造的 Date 头
- Web 分页排序补 id DESC tiebreak,与 IMAP 全链路一致
- ListDeletedByUserAndFolder 同步改为 id ASC
- 测试:TestSeqStoreClientSelfNumbered 替换为 TestSeqOrderArrival,
  断言到达顺序序号映射、旧 Date 头新邮件落在末尾、INTERNALDATE=CreatedAt
2026-08-20 15:18:55 +08:00

308 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"errors"
"time"
"mail_go/internal/db"
"gorm.io/gorm"
)
// Common store errors
var (
ErrInvalidEmail = errors.New("无效的邮箱地址格式")
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrUserInactive = errors.New("用户已被禁用")
ErrRecordNotFound = errors.New("记录不存在")
)
// MailStore defines the interface for mail message operations.
type MailStore interface {
Create(msg *db.Message) error
GetByID(id uint) (*db.Message, error)
ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error)
ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error)
CountByUserAndFolder(userID uint, folder string) (int64, error)
MaxIDByUserAndFolder(userID uint, folder string) (uint, error)
MarkRead(id uint) error
MarkReadState(id uint, read bool) error
MarkFlagged(id uint, flagged bool) error
// SetReadStates 批量设置多封邮件的已读状态(单条 UPDATE ... IN)。
SetReadStates(ids []uint, read bool) error
// SetFlaggedStates 批量设置多封邮件的星标状态(单条 UPDATE ... IN)。
SetFlaggedStates(ids []uint, flagged bool) error
MoveToFolder(id uint, folder string) error
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
SetDeletedStates(ids []uint, deleted bool) error
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
DeleteMany(ids []uint) error
Delete(id uint) error
CountUnread(userID uint, folder string) (int64, error)
CountByFolder(folder string) (int64, error)
CountAll() (int64, error)
TotalSizeByFolder(folder string) (int64, error)
TotalSize() (int64, error)
CountByFolderSince(folder string, since time.Time) (int64, error)
ListAll(page, size int) ([]db.Message, int64, error)
ListAllByFolder(folder string, page, size int) ([]db.Message, int64, error)
}
// mailStoreGorm implements MailStore using GORM.
type mailStoreGorm struct {
db *gorm.DB
}
// newMailStore creates a new GORM-backed MailStore.
func newMailStore(database *gorm.DB) MailStore {
return &mailStoreGorm{db: database}
}
// Create inserts a new message record.
func (s *mailStoreGorm) Create(msg *db.Message) error {
// 日期统一为 UTC 存储:date 列在 SQLite 中是文本,混合时区偏移
// +08:00/-04:00 等)会让 ORDER BY date 变成错误的字典序(Web 列表
// 排序错乱、IMAP 序号与客户端日期视图不一致)。统一 UTC 后字典序
// 即时间序,所有排序路径(Web/IMAP/seqOf)全链路一致。
if msg != nil {
msg.Date = msg.Date.UTC()
}
return s.db.Create(msg).Error
}
// GetByID retrieves a message by primary key.
func (s *mailStoreGorm) GetByID(id uint) (*db.Message, error) {
var msg db.Message
if err := s.db.First(&msg, id).Error; err != nil {
return nil, err
}
return &msg, nil
}
// ListByUserAndFolder retrieves a paginated list of messages for a user and folder.
func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error) {
var messages []db.Message
var total int64
query := s.db.Where("user_id = ? AND folder = ?", userID, folder)
if err := query.Model(&db.Message{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := query.Order("date DESC, id DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
return nil, 0, err
}
return messages, total, nil
}
// MarkRead sets the IsRead flag to true for a message.
func (s *mailStoreGorm) MarkRead(id uint) error {
return s.MarkReadState(id, true)
}
// MarkReadState sets the IsRead flag for a message.
func (s *mailStoreGorm) MarkReadState(id uint, read bool) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_read", read).Error
}
// MarkFlagged sets the IsFlagged flag for a message.
func (s *mailStoreGorm) MarkFlagged(id uint, flagged bool) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error
}
// SetReadStates 批量设置多封邮件的已读状态。
// 客户端整批标记已读(手机同步后 STORE +FLAGS \Seen)时,逐条 UPDATE
// 会产生大量写事务并占住连接,这里合并为单条 SQL。
func (s *mailStoreGorm) SetReadStates(ids []uint, read bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_read", read).Error
}
// SetFlaggedStates 批量设置多封邮件的星标状态。
func (s *mailStoreGorm) SetFlaggedStates(ids []uint, flagged bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_flagged", flagged).Error
}
// MoveToFolder changes the folder of a message.
func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error
}
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记。
func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
}
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
var messages []db.Message
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
Order("id ASC").Find(&messages).Error; err != nil {
return nil, err
}
return messages, nil
}
// DeleteMany 批量硬删除多封邮件。
func (s *mailStoreGorm) DeleteMany(ids []uint) error {
if len(ids) == 0 {
return nil
}
return s.db.Where("id IN ?", ids).Delete(&db.Message{}).Error
}
// Delete removes a message by ID.
func (s *mailStoreGorm) Delete(id uint) error {
return s.db.Delete(&db.Message{}, id).Error
}
// CountUnread returns the count of unread messages for a user in a folder.
func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
var count int64
if err := s.db.Model(&db.Message{}).
Where("user_id = ? AND folder = ? AND is_read = ?", userID, folder, false).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
// 按 id ASC(到达顺序,最早在前)排序:新邮件永远获得最大序号(seq =
// EXISTS 数),与主流服务器(Dovecot/Courier)行为一致,依赖「新邮件 =
// seq N+1」做增量同步的客户端不会漏收或标错邮件;新邮件到达不会使既有
// 邮件序号位移(只有 EXPUNGE 才会,属正常行为)。所有 IMAP 序号相关路径
// Status/Fetch/Search/Store/Copy/Move/Expunge/推送/seqOf)共用本排序,
// 保证序号全链路一致。INTERNALDATE 使用 CreatedAt(到达时间),与排序一致。
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
var messages []db.Message
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
Order("id ASC").Find(&messages).Error; err != nil {
return nil, err
}
return messages, nil
}
// CountByUserAndFolder returns the total count of messages for a user in a folder.
func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64, error) {
var count int64
if err := s.db.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, folder).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// MaxIDByUserAndFolder returns the highest message ID for a user folder.
func (s *mailStoreGorm) MaxIDByUserAndFolder(userID uint, folder string) (uint, error) {
var maxID uint
if err := s.db.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, folder).
Select("COALESCE(MAX(id), 0)").
Scan(&maxID).Error; err != nil {
return 0, err
}
return maxID, nil
}
// CountByFolder returns the total count of messages in a given folder.
func (s *mailStoreGorm) CountByFolder(folder string) (int64, error) {
var count int64
if err := s.db.Model(&db.Message{}).Where("folder = ?", folder).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CountAll returns the total count of all messages.
func (s *mailStoreGorm) CountAll() (int64, error) {
var count int64
if err := s.db.Model(&db.Message{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// TotalSizeByFolder returns the total size (in bytes) of message bodies in a given folder.
func (s *mailStoreGorm) TotalSizeByFolder(folder string) (int64, error) {
var total int64
err := s.db.Model(&db.Message{}).
Where("folder = ?", folder).
Select("COALESCE(SUM(LENGTH(text_body) + LENGTH(html_body)), 0)").
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
// TotalSize returns the total size (in bytes) of all message bodies.
func (s *mailStoreGorm) TotalSize() (int64, error) {
var total int64
err := s.db.Model(&db.Message{}).
Select("COALESCE(SUM(LENGTH(text_body) + LENGTH(html_body)), 0)").
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
// CountByFolderSince returns the count of messages in a folder since a given time.
func (s *mailStoreGorm) CountByFolderSince(folder string, since time.Time) (int64, error) {
var count int64
if err := s.db.Model(&db.Message{}).
Where("folder = ? AND created_at >= ?", folder, since).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// ListAll retrieves a paginated list of all messages across all users.
func (s *mailStoreGorm) ListAll(page, size int) ([]db.Message, int64, error) {
var messages []db.Message
var total int64
if err := s.db.Model(&db.Message{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := s.db.Preload("User").Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
return nil, 0, err
}
return messages, total, nil
}
// ListAllByFolder retrieves a paginated list of all messages in a given folder across all users.
func (s *mailStoreGorm) ListAllByFolder(folder string, page, size int) ([]db.Message, int64, error) {
var messages []db.Message
var total int64
query := s.db.Where("folder = ?", folder)
if err := query.Model(&db.Message{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := s.db.Preload("User").Where("folder = ?", folder).Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
return nil, 0, err
}
return messages, total, nil
}