Files
mailgo/internal/store/mail_store.go
T
kevin 962c5f454c fix(imap): 升级 go-imap v2 + \Deleted 落库,修复删除邮件只复制到垃圾桶
删除流程(Apple Mail/iOS 等):UID COPY→Trash + UID STORE \Deleted +
UID EXPUNGE。此前 go-imap v1.2.1 不支持 UID EXPUNGE(回
Command unsupported with UID),且 \Deleted 只存内存会话 map(重选/
重连即丢失),导致 EXPUNGE 不删原邮件——垃圾桶只有副本、INBOX 原封
不动。

- 依赖:go-imap v1.2.1 → go-imap/v2 v2.0.0-beta.8(原生支持 UID
  EXPUNGE,且库为 race-clean,移除集成测试 !race 标签)
- Message 新增 IsDeleted 字段持久化 \Deleted 标记(AutoMigrate 自动
  加列,SQLite/MySQL 通用;migrate 工具随模型字段自动复制)
- IMAP 层重写为 v2 imapserver.Session 架构(session.go 替代
  backend.go):SELECT/STATUS/LIST/FETCH/SEARCH/STORE/COPY/MOVE/
  APPEND/EXPUNGE/Poll/Idle;EXPUNGE 按数据库 IsDeleted 删除,UID
  EXPUNGE 只删集合内已标记消息
- server.go:显式能力集(IMAP4rev1+UIDPLUS+MOVE+LITERAL++CHILDREN+
  SPECIAL-USE);自实现 mailboxHub 跨会话推送(含来源排除,避免 v2
  库 MailboxTracker 对 EXPUNGE/EXISTS 的回声重复响应);Pusher 接口
  签名不变,Web/SMTP/POP3 调用方零改动;DisconnectByAddr 改用会话
  注册表 + conn.Bye()
- 测试:集成测试改用 v2 imapclient,新增删除流程/跨会话持久化/MOVE
  回归用例;notify_test 适配 hub 推送模型并覆盖来源排除与
  allowExpunge 语义
2026-08-20 00:44:37 +08:00

305 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 的邮件
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
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").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 的邮件。
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("date DESC, id DESC").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.
// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、
// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会
// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf
// 共用本排序,保证序号全链路一致。
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("date DESC, id DESC").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
}