feat(mailbox): 文件夹数据驱动,Web 通过 IMAP 共享服务层操作邮箱

- 新增 mailboxes 表与 MailboxStore(系统文件夹幂等创建,自定义文件夹 CRUD)
- 提取 MailboxService:IMAP 会话与 Web handler 共用,LIST 返回什么 Web 就显示什么
- IMAP 支持 CREATE/DELETE/RENAME/SUBSCRIBE(系统文件夹禁删改、非空禁删)
- Web 删除改为 IMAP 语义:移入 Trash,新增恢复/彻底删除/清空
- 新增通用 /folder/:name 页面与动态侧边栏,/inbox /sent /drafts 保留兼容重定向
This commit is contained in:
2026-08-20 01:25:40 +08:00
parent 962c5f454c
commit dc164fdf66
22 files changed
+1440 -639

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
package store
import (
"errors"
"mail_go/internal/db"
"gorm.io/gorm"
)
// Mailbox store errors(英文文案:IMAP 响应直接使用,Web 侧另行提示)。
var (
ErrMailboxNotFound = errors.New("No such mailbox")
ErrMailboxExists = errors.New("mailbox already exists")
ErrMailboxInvalid = errors.New("invalid mailbox name")
ErrMailboxSystem = errors.New("system mailbox cannot be deleted or renamed")
ErrMailboxNotEmpty = errors.New("mailbox is not empty")
)
// MailboxStore defines the interface for mailbox (folder) operations.
type MailboxStore interface {
// EnsureSystem 幂等创建用户的系统文件夹(INBOX/Sent/Drafts/Trash)。
EnsureSystem(userID uint) error
// List 返回用户全部文件夹:系统文件夹按规范顺序在前,
// 自定义文件夹按名称升序在后。
List(userID uint) ([]db.Mailbox, error)
GetByName(userID uint, name string) (*db.Mailbox, error)
Create(mb *db.Mailbox) error
// Delete 删除空的自定义文件夹(含对应的 mailbox_states 记录)。
Delete(userID uint, name string) error
// Rename 重命名文件夹并同步迁移其邮件与 mailbox_states 记录。
Rename(userID uint, oldName, newName string) error
SetSubscribed(userID uint, name string, subscribed bool) error
}
// mailboxStoreGorm implements MailboxStore using GORM.
type mailboxStoreGorm struct {
db *gorm.DB
}
// newMailboxStore creates a new GORM-backed MailboxStore.
func newMailboxStore(database *gorm.DB) MailboxStore {
return &mailboxStoreGorm{db: database}
}
// isSystemMailboxName reports whether name is one of the system mailboxes.
func isSystemMailboxName(name string) bool {
for _, def := range db.SystemMailboxes {
if def.Name == name {
return true
}
}
return false
}
// EnsureSystem creates missing system mailboxes for the user.
// 已存在的行不覆盖:用户此前若已退订某系统文件夹(LSUB),状态保留。
func (s *mailboxStoreGorm) EnsureSystem(userID uint) error {
var existing []db.Mailbox
if err := s.db.Where("user_id = ?", userID).Find(&existing).Error; err != nil {
return err
}
have := make(map[string]bool, len(existing))
for _, mb := range existing {
have[mb.Name] = true
}
for _, def := range db.SystemMailboxes {
if have[def.Name] {
continue
}
mb := &db.Mailbox{
UserID: userID,
Name: def.Name,
SpecialUse: def.SpecialUse,
IsSubscribed: true,
}
if err := s.db.Create(mb).Error; err != nil {
return err
}
}
return nil
}
// List returns all folders for a user with system folders first.
func (s *mailboxStoreGorm) List(userID uint) ([]db.Mailbox, error) {
var mbs []db.Mailbox
if err := s.db.Where("user_id = ?", userID).Order("name").Find(&mbs).Error; err != nil {
return nil, err
}
byName := make(map[string]db.Mailbox, len(mbs))
for _, mb := range mbs {
byName[mb.Name] = mb
}
sys := make([]db.Mailbox, 0, len(db.SystemMailboxes))
for _, def := range db.SystemMailboxes {
if mb, ok := byName[def.Name]; ok {
sys = append(sys, mb)
}
}
custom := make([]db.Mailbox, 0, len(mbs))
for _, mb := range mbs {
if !isSystemMailboxName(mb.Name) {
custom = append(custom, mb)
}
}
return append(sys, custom...), nil
}
// GetByName returns a folder by its exact name.
func (s *mailboxStoreGorm) GetByName(userID uint, name string) (*db.Mailbox, error) {
var mb db.Mailbox
if err := s.db.Where("user_id = ? AND name = ?", userID, name).First(&mb).Error; err != nil {
return nil, err
}
return &mb, nil
}
// Create inserts a new mailbox row.
func (s *mailboxStoreGorm) Create(mb *db.Mailbox) error {
return s.db.Create(mb).Error
}
// Delete removes an empty mailbox and its mailbox_states record.
func (s *mailboxStoreGorm) Delete(userID uint, name string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, name).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrMailboxNotEmpty
}
if err := tx.Where("user_id = ? AND name = ?", userID, name).
Delete(&db.Mailbox{}).Error; err != nil {
return err
}
return tx.Where("user_id = ? AND folder = ?", userID, name).
Delete(&db.MailboxState{}).Error
})
}
// Rename renames a mailbox, moving its messages along with it.
func (s *mailboxStoreGorm) Rename(userID uint, oldName, newName string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&db.Mailbox{}).
Where("user_id = ? AND name = ?", userID, oldName).
Update("name", newName).Error; err != nil {
return err
}
if err := tx.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, oldName).
Update("folder", newName).Error; err != nil {
return err
}
// 旧 UIDVALIDITY 不再适用:删除状态记录,下次访问重新生成,
// 客户端据此丢弃缓存并全量重同步。
return tx.Where("user_id = ? AND folder = ?", userID, oldName).
Delete(&db.MailboxState{}).Error
})
}
// SetSubscribed updates the subscription flag of a mailbox.
func (s *mailboxStoreGorm) SetSubscribed(userID uint, name string, subscribed bool) error {
return s.db.Model(&db.Mailbox{}).
Where("user_id = ? AND name = ?", userID, name).
Update("is_subscribed", subscribed).Error
}
+141
View File
@@ -0,0 +1,141 @@
package store
import (
"testing"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// newMailboxTestStore 返回基于内存库的 MailboxStore(含 mailboxes 表)。
func newMailboxTestStore(t *testing.T) (MailboxStore, *gorm.DB) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.Mailbox{}, &db.Message{}, &db.MailboxState{}); err != nil {
t.Fatal(err)
}
return newMailboxStore(gdb), gdb
}
// TestMailboxEnsureSystem 验证系统文件夹幂等创建与规范排序。
func TestMailboxEnsureSystem(t *testing.T) {
s, _ := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatalf("EnsureSystem: %v", err)
}
// 幂等:再次调用不报错、不产生重复行
if err := s.EnsureSystem(1); err != nil {
t.Fatalf("EnsureSystem 2nd: %v", err)
}
mbs, err := s.List(1)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(mbs) != 4 {
t.Fatalf("len(List) = %d, want 4", len(mbs))
}
want := []string{"INBOX", "Sent", "Drafts", "Trash"}
for i, name := range want {
if mbs[i].Name != name {
t.Fatalf("List[%d] = %q, want %q", i, mbs[i].Name, name)
}
}
if mbs[3].SpecialUse != "Trash" {
t.Fatalf("Trash SpecialUse = %q, want Trash", mbs[3].SpecialUse)
}
}
// TestMailboxCustomFolderLifecycle 验证自定义文件夹:创建/排序/非空删除拒绝。
func TestMailboxCustomFolderLifecycle(t *testing.T) {
s, gdb := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.Create(&db.Mailbox{UserID: 1, Name: "工作", IsSubscribed: true}); err != nil {
t.Fatalf("Create: %v", err)
}
mbs, err := s.List(1)
if err != nil {
t.Fatal(err)
}
if len(mbs) != 5 || mbs[4].Name != "工作" {
t.Fatalf("custom folder not last: %+v", mbs)
}
// 放一封邮件进去 → 非空文件夹不可删除
msg := &db.Message{UserID: 1, Folder: "工作", FromAddr: "a@b", Subject: "s"}
if err := gdb.Create(msg).Error; err != nil {
t.Fatal(err)
}
if err := s.Delete(1, "工作"); err != ErrMailboxNotEmpty {
t.Fatalf("Delete non-empty mailbox = %v, want ErrMailboxNotEmpty", err)
}
// 清空后可删除
if err := gdb.Where("id = ?", msg.ID).Delete(&db.Message{}).Error; err != nil {
t.Fatal(err)
}
if err := s.Delete(1, "工作"); err != nil {
t.Fatalf("Delete empty mailbox: %v", err)
}
if _, err := s.GetByName(1, "工作"); err == nil {
t.Fatal("mailbox should be gone after Delete")
}
}
// TestMailboxRenameMovesMessages 验证重命名同步迁移邮件。
func TestMailboxRenameMovesMessages(t *testing.T) {
s, gdb := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.Create(&db.Mailbox{UserID: 1, Name: "旧名字", IsSubscribed: true}); err != nil {
t.Fatal(err)
}
msg := &db.Message{UserID: 1, Folder: "旧名字", FromAddr: "a@b", Subject: "s"}
if err := gdb.Create(msg).Error; err != nil {
t.Fatal(err)
}
if err := s.Rename(1, "旧名字", "新名字"); err != nil {
t.Fatalf("Rename: %v", err)
}
var count int64
if err := gdb.Model(&db.Message{}).
Where("user_id = 1 AND folder = ?", "新名字").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("messages in 新名字 = %d, want 1", count)
}
if _, err := s.GetByName(1, "旧名字"); err == nil {
t.Fatal("old mailbox name should be gone")
}
}
// TestMailboxSubscribed 验证订阅状态写入。
func TestMailboxSubscribed(t *testing.T) {
s, _ := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.SetSubscribed(1, "Trash", false); err != nil {
t.Fatalf("SetSubscribed: %v", err)
}
mb, err := s.GetByName(1, "Trash")
if err != nil {
t.Fatal(err)
}
if mb.IsSubscribed {
t.Fatal("Trash should be unsubscribed")
}
}
+3
View File
@@ -16,6 +16,7 @@ type Stores struct {
Outbound OutboundStore
ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore
Mailboxes MailboxStore
}
// NewStores creates a new Stores instance with all GORM-backed implementations.
@@ -29,6 +30,7 @@ func NewStores(database *gorm.DB) *Stores {
Outbound: newOutboundStore(database),
ProtocolLogs: newProtocolLogStore(database),
MailboxState: newMailboxStateStore(database),
Mailboxes: newMailboxStore(database),
}
}
@@ -40,3 +42,4 @@ var _ = db.Attachment{}
var _ = db.BanEntry{}
var _ = db.ProtocolLog{}
var _ = db.MailboxState{}
var _ = db.Mailbox{}