diff --git a/docs/architecture.md b/docs/architecture.md index 05a17bb..82e8629 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -413,14 +413,16 @@ type imapMailbox struct { | GET | /login | auth.ShowLogin | — | 登录页 | | POST | /login | auth.DoLogin | — | 登录提交 | | POST | /logout | auth.DoLogout | Auth | 登出 | -| GET | / | mail.Inbox | Auth | 收件箱(重定向到 /inbox) | -| GET | /inbox | mail.Inbox | Auth | 收件箱列表 | -| GET | /inbox/:id | mail.View | Auth | 查看邮件 | +| GET | / | — | Auth | 收件箱(重定向到 /inbox) | +| GET | /folder/:name | mail.Folder | Auth | 通用文件夹页(目录与 IMAP LIST 同源) | +| GET | /folder/:name/:id | mail.View | Auth | 查看文件夹内邮件 | +| POST | /folder/:name/empty | mail.EmptyFolder | Auth | 清空文件夹 | +| GET | /inbox /sent /drafts(及 /:id) | — | Auth | 旧路径兼容重定向到 /folder/ | | GET | /compose | mail.Compose | Auth | 撰写页面 | | POST | /compose | mail.DoSend | Auth | 发送邮件 | -| GET | /sent | mail.Sent | Auth | 发件箱 | -| GET | /sent/:id | mail.View | Auth | 查看已发送邮件 | -| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件 | +| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件(移入 Trash;Trash 内为彻底删除) | +| POST | /mail/restore/:id | mail.Restore | Auth | 恢复邮件到收件箱 | +| POST | /mail/purge/:id | mail.Purge | Auth | 彻底删除邮件 | | POST | /mail/read/:id | mail.MarkRead | Auth | 标记已读 | | GET | /attachment/:id | mail.DownloadAttachment | Auth | 下载附件 | | GET | /admin | admin.Dashboard | Auth + Admin | 管理后台首页 | @@ -594,7 +596,7 @@ sequenceDiagram - 管理后台 handler:域名 CRUD、用户 CRUD、DNS 提示 - AttachmentStorage:附件文件写入/读取/删除磁盘操作 -- 所有 HTML 模板:base 布局 + login/inbox/compose/sent/view/admin 系列页面 +- 所有 HTML 模板:base 布局 + login/folder/compose/view/admin 系列页面 - 附件上传(compose 页面多文件上传)+ 下载 handler **T05: 集成调试 + 安装脚本** diff --git a/internal/db/db.go b/internal/db/db.go index 33c76f6..7aab395 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -56,7 +56,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D } // Auto-migrate all models - if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}); err != nil { + if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}, &Mailbox{}); err != nil { return nil, fmt.Errorf("数据库迁移失败: %w", err) } diff --git a/internal/db/models.go b/internal/db/models.go index 9f2c6f9..ca6b725 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -177,6 +177,39 @@ func (Attachment) TableName() string { return "attachments" } +// SystemMailbox 定义一个系统预置文件夹(RFC 6154 SPECIAL-USE 角色)。 +type SystemMailbox struct { + Name string // 规范名(INBOX 大小写不敏感) + SpecialUse string // Sent / Drafts / Trash;INBOX 为 "" +} + +// SystemMailboxes 是系统预置文件夹清单:IMAP LIST 与 Web 侧边栏 +// 共用此定义,保证「IMAP 返回什么,Web 就显示什么」。 +var SystemMailboxes = []SystemMailbox{ + {Name: "INBOX", SpecialUse: ""}, + {Name: "Sent", SpecialUse: "Sent"}, + {Name: "Drafts", SpecialUse: "Drafts"}, + {Name: "Trash", SpecialUse: "Trash"}, +} + +// Mailbox 表示一个用户文件夹(IMAP mailbox / Web 侧边栏条目)。 +// 系统文件夹在首次访问时由 MailboxStore.EnsureSystem 幂等创建; +// 自定义文件夹经 IMAP CREATE 创建。 +type Mailbox struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_mailbox_user_name;not null" json:"user_id"` + Name string `gorm:"size:64;uniqueIndex:idx_mailbox_user_name;not null" json:"name"` + SpecialUse string `gorm:"size:16" json:"special_use"` // 自定义文件夹为空 + IsSubscribed bool `gorm:"default:true" json:"is_subscribed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName specifies the table name for Mailbox. +func (Mailbox) TableName() string { + return "mailboxes" +} + // MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。 // UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间 // 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存 diff --git a/internal/imap_server/integration_test.go b/internal/imap_server/integration_test.go index 122b47a..ee727cc 100644 --- a/internal/imap_server/integration_test.go +++ b/internal/imap_server/integration_test.go @@ -28,7 +28,7 @@ func startIntegrationServer(t *testing.T) (*store.Stores, string) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}, &db.MailboxState{}); err != nil { + if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}, &db.MailboxState{}, &db.Mailbox{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) @@ -377,3 +377,125 @@ func TestUidMoveFlow(t *testing.T) { t.Fatalf("moved msg folder = %q, want Trash", msg.Folder) } } + +// TestListDataDriven 验证文件夹目录由 mailboxes 表驱动:LIST 返回 +// 4 个系统文件夹 + CREATE 的自定义文件夹,且带正确的 SPECIAL-USE 属性; +// DELETE 非空/系统文件夹被拒绝,LSUB 按订阅过滤。 +func TestListDataDriven(t *testing.T) { + stores, addr := startIntegrationServer(t) + + c, err := imapclient.DialInsecure(addr, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { c.Logout().Wait() }) + if err := c.Login("alice@example.com", "secret123").Wait(); err != nil { + t.Fatalf("login: %v", err) + } + + // LIST:系统文件夹齐全且属性正确 + datas, err := c.List("", "*", nil).Collect() + if err != nil { + t.Fatalf("list: %v", err) + } + byName := map[string]*imap.ListData{} + for _, d := range datas { + byName[d.Mailbox] = d + } + for _, want := range []string{"INBOX", "Sent", "Drafts", "Trash"} { + if _, ok := byName[want]; !ok { + t.Fatalf("LIST 缺少 %s", want) + } + } + if !containsAttr(byName["Trash"].Attrs, imap.MailboxAttrTrash) { + t.Fatalf("Trash attrs = %v, want \\Trash", byName["Trash"].Attrs) + } + if !containsAttr(byName["Sent"].Attrs, imap.MailboxAttrSent) { + t.Fatalf("Sent attrs = %v, want \\Sent", byName["Sent"].Attrs) + } + + // CREATE 自定义文件夹 → LIST 立即可见 + if err := c.Create("工作", nil).Wait(); err != nil { + t.Fatalf("create: %v", err) + } + datas2, err := c.List("", "*", nil).Collect() + if err != nil { + t.Fatalf("list after create: %v", err) + } + found := false + for _, d := range datas2 { + if d.Mailbox == "工作" { + found = true + } + } + if !found { + t.Fatal("CREATE 后的自定义文件夹未出现在 LIST 中") + } + + // 重名创建 → NO + if err := c.Create("工作", nil).Wait(); err == nil { + t.Fatal("重复 CREATE 应返回 NO") + } + + // 系统文件夹不可删除 + if err := c.Delete("Trash").Wait(); err == nil { + t.Fatal("删除系统文件夹应返回 NO") + } + + // 非空自定义文件夹不可删除 + ids := seedMailbox(t, stores, 1, 1) + if _, err := c.Select("INBOX", nil).Wait(); err != nil { + t.Fatal(err) + } + if _, err := c.Copy(imap.UIDSetNum(imap.UID(ids[0])), "工作").Wait(); err != nil { + t.Fatalf("copy to custom: %v", err) + } + if err := c.Delete("工作").Wait(); err == nil { + t.Fatal("删除非空文件夹应返回 NO") + } + + // RENAME 自定义文件夹 + if err := c.Rename("工作", "归档", nil).Wait(); err != nil { + t.Fatalf("rename: %v", err) + } + datas3, err := c.List("", "*", nil).Collect() + if err != nil { + t.Fatalf("list after rename: %v", err) + } + renamed := false + for _, d := range datas3 { + if d.Mailbox == "归档" { + renamed = true + } + if d.Mailbox == "工作" { + t.Fatal("旧文件夹名仍出现在 LIST 中") + } + } + if !renamed { + t.Fatal("重命名后的文件夹未出现在 LIST 中") + } + + // UNSUBSCRIBE → LSUB 不再返回 + if err := c.Unsubscribe("归档").Wait(); err != nil { + t.Fatalf("unsubscribe: %v", err) + } + lsub, err := c.List("", "*", &imap.ListOptions{SelectSubscribed: true}).Collect() + if err != nil { + t.Fatalf("lsub: %v", err) + } + for _, d := range lsub { + if d.Mailbox == "归档" { + t.Fatal("退订后的文件夹不应出现在 LSUB 中") + } + } +} + +// containsAttr 判断属性列表是否包含目标属性。 +func containsAttr(attrs []imap.MailboxAttr, want imap.MailboxAttr) bool { + for _, a := range attrs { + if a == want { + return true + } + } + return false +} diff --git a/internal/imap_server/notify_test.go b/internal/imap_server/notify_test.go index 8ecfa5b..e26758f 100644 --- a/internal/imap_server/notify_test.go +++ b/internal/imap_server/notify_test.go @@ -27,7 +27,7 @@ func newTestServer(t *testing.T) (*IMAPServer, *store.Stores) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.MailboxState{}); err != nil { + if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.MailboxState{}, &db.Mailbox{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index f70e180..0ae5307 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -37,6 +37,9 @@ type IMAPServer struct { tlsLoader *tlsutil.Loader hub *connhub.Hub + // svc 邮箱服务层(文件夹目录/消息操作),IMAP 会话与 Web 共用。 + svc *MailboxService + // hubs 按「用户邮箱 + 文件夹」索引的推送中心,会话 SELECT 时加入。 mu sync.Mutex hubs map[string]*mailboxHub @@ -53,11 +56,17 @@ func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsut banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, + svc: NewMailboxService(stores), hubs: make(map[string]*mailboxHub), sessions: make(map[*imapSession]struct{}), } } +// MailboxService 返回邮箱服务层(Web handler 共用)。 +func (s *IMAPServer) MailboxService() *MailboxService { + return s.svc +} + // hubKey 生成推送中心索引键。 func hubKey(userEmail, mailbox string) string { return userEmail + "\x00" + mailbox @@ -91,15 +100,17 @@ func (s *IMAPServer) unregisterSession(sess *imapSession) { // NotifyNewMessage 推送新邮件到达通知:EXISTS 计数 + 标记更新。 // 由 SMTP/Web 本地投递成功时调用;无会话选中该邮箱时为 no-op。 +// 推送目标邮箱取 msg.Folder(本地投递为 INBOX;Web 移动/恢复时 +// 用于通知目标文件夹,如 Trash)。 func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) { - if s == nil || userEmail == "" || msg == nil { + if s == nil || userEmail == "" || msg == nil || msg.Folder == "" { return } - hub := s.hubFor(userEmail, "INBOX") + hub := s.hubFor(userEmail, msg.Folder) if hub == nil { return } - if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, "INBOX"); err == nil { + if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, msg.Folder); err == nil { hub.enqueue(sessionUpdate{exists: ptrU32(uint32(count))}, nil) } } diff --git a/internal/imap_server/service.go b/internal/imap_server/service.go new file mode 100644 index 0000000..d9c2951 --- /dev/null +++ b/internal/imap_server/service.go @@ -0,0 +1,335 @@ +package imap_server + +// MailboxService 是邮箱(文件夹)与消息操作的服务层: +// IMAP 会话与 Web handler 共用同一份实现,保证「IMAP LIST 返回什么, +// Web 就显示什么」,且移动/删除等操作走同一语义(如移入 Trash)。 + +import ( + "log" + "strings" + + "mail_go/internal/db" + "mail_go/internal/store" + + "github.com/emersion/go-imap/v2" +) + +// FolderInfo 是一个文件夹在列表页(IMAP LIST / Web 侧边栏) +// 展示所需的信息。 +type FolderInfo struct { + Name string + SpecialUse string + Subscribed bool + Total int64 + Unseen int64 +} + +// MailboxService 提供用户级邮箱操作。 +type MailboxService struct { + stores *store.Stores +} + +// NewMailboxService creates a MailboxService backed by stores. +func NewMailboxService(stores *store.Stores) *MailboxService { + return &MailboxService{stores: stores} +} + +// ListAll 返回用户全部文件夹(先确保系统文件夹存在)。 +func (s *MailboxService) ListAll(userID uint) ([]db.Mailbox, error) { + if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil { + return nil, err + } + return s.stores.Mailboxes.List(userID) +} + +// List 返回用户全部文件夹及统计信息(IMAP LIST / Web 侧边栏同源)。 +func (s *MailboxService) List(userID uint) ([]FolderInfo, error) { + mbs, err := s.ListAll(userID) + if err != nil { + return nil, err + } + infos := make([]FolderInfo, 0, len(mbs)) + for _, mb := range mbs { + total, err := s.stores.Mails.CountByUserAndFolder(userID, mb.Name) + if err != nil { + log.Printf("mailbox: 统计 %s 邮件数失败: %v", mb.Name, err) + } + unseen, err := s.stores.Mails.CountUnread(userID, mb.Name) + if err != nil { + log.Printf("mailbox: 统计 %s 未读数失败: %v", mb.Name, err) + } + infos = append(infos, FolderInfo{ + Name: mb.Name, + SpecialUse: mb.SpecialUse, + Subscribed: mb.IsSubscribed, + Total: total, + Unseen: unseen, + }) + } + return infos, nil +} + +// Canonical 规范化邮箱名:INBOX 大小写不敏感,其余按 DB 实际名称 +// 精确匹配。会先确保系统文件夹存在(客户端可能不 LIST 直接 SELECT)。 +func (s *MailboxService) Canonical(userID uint, name string) (string, bool) { + name = strings.TrimSpace(name) + if name == "" { + return "", false + } + if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil { + log.Printf("mailbox: EnsureSystem 失败 user=%d: %v", userID, err) + return "", false + } + if strings.EqualFold(name, "INBOX") { + return "INBOX", true + } + mb, err := s.stores.Mailboxes.GetByName(userID, name) + if err != nil { + return "", false + } + return mb.Name, true +} + +// Messages 分页返回用户某文件夹的邮件。 +func (s *MailboxService) Messages(userID uint, name string, page, size int) ([]db.Message, int64, error) { + return s.stores.Mails.ListByUserAndFolder(userID, name, page, size) +} + +// Select 计算 SELECT 响应数据(会话负责 hub 绑定与选中状态)。 +func (s *MailboxService) Select(userID uint, name string) (*imap.SelectData, error) { + msgs, err := s.stores.Mails.ListAllByUserAndFolder(userID, name) + if err != nil { + return nil, err + } + maxID, err := s.stores.Mails.MaxIDByUserAndFolder(userID, name) + if err != nil { + return nil, err + } + uidValidity, err := s.stores.MailboxState.UidValidity(userID, name) + if err != nil { + log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", name, err) + uidValidity = 1 + } + flags := []imap.Flag{imap.FlagAnswered, imap.FlagFlagged, imap.FlagDeleted, imap.FlagSeen, imap.FlagDraft} + return &imap.SelectData{ + Flags: flags, + PermanentFlags: append(flags, imap.FlagWildcard), + NumMessages: uint32(len(msgs)), + NumRecent: 0, + UIDNext: imap.UID(maxID + 1), + UIDValidity: uidValidity, + }, nil +} + +// Status 计算 STATUS 响应数据。 +func (s *MailboxService) Status(userID uint, name string, options *imap.StatusOptions) (*imap.StatusData, error) { + msgs, err := s.stores.Mails.ListAllByUserAndFolder(userID, name) + if err != nil { + return nil, err + } + data := &imap.StatusData{Mailbox: name} + + if options.NumMessages || options.NumUnseen || options.NumDeleted || options.Size { + var unseen, deleted uint32 + var size int64 + for i := range msgs { + if !msgs[i].IsRead { + unseen++ + } + if msgs[i].IsDeleted { + deleted++ + } + size += int64(len(messageRawData(&msgs[i]))) + } + if options.NumMessages { + n := uint32(len(msgs)) + data.NumMessages = &n + } + if options.NumUnseen { + data.NumUnseen = &unseen + } + if options.NumDeleted { + data.NumDeleted = &deleted + } + if options.Size { + data.Size = &size + } + } + if options.NumRecent { + zero := uint32(0) + data.NumRecent = &zero + } + if options.UIDNext { + maxID, err := s.stores.Mails.MaxIDByUserAndFolder(userID, name) + if err != nil { + return nil, err + } + data.UIDNext = imap.UID(maxID + 1) + } + if options.UIDValidity { + uidValidity, err := s.stores.MailboxState.UidValidity(userID, name) + if err != nil { + return nil, err + } + data.UIDValidity = uidValidity + } + return data, nil +} + +// validateMailboxName 校验自定义文件夹名(IMAP CREATE/RENAME 用)。 +func validateMailboxName(name string) error { + if name == "" || len(name) > 64 { + return store.ErrMailboxInvalid + } + if strings.Contains(name, "/") || name == "." || name == ".." { + return store.ErrMailboxInvalid + } + for _, r := range name { + if r < 0x20 || r == 0x7f { + return store.ErrMailboxInvalid + } + } + return nil +} + +// Create 创建自定义文件夹。 +func (s *MailboxService) Create(userID uint, name string) error { + name = strings.TrimSpace(name) + if err := validateMailboxName(name); err != nil { + return err + } + if strings.EqualFold(name, "INBOX") { + return store.ErrMailboxExists + } + if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil { + return err + } + if _, err := s.stores.Mailboxes.GetByName(userID, name); err == nil { + return store.ErrMailboxExists + } + return s.stores.Mailboxes.Create(&db.Mailbox{ + UserID: userID, + Name: name, + IsSubscribed: true, + }) +} + +// Delete 删除空的自定义文件夹(系统文件夹拒绝)。 +func (s *MailboxService) Delete(userID uint, name string) error { + name, ok := s.Canonical(userID, name) + if !ok { + return store.ErrMailboxNotFound + } + if isSystemMailboxName(name) { + return store.ErrMailboxSystem + } + return s.stores.Mailboxes.Delete(userID, name) +} + +// Rename 重命名自定义文件夹(系统文件夹拒绝)。 +func (s *MailboxService) Rename(userID uint, oldName, newName string) error { + oldName, ok := s.Canonical(userID, oldName) + if !ok { + return store.ErrMailboxNotFound + } + if isSystemMailboxName(oldName) { + return store.ErrMailboxSystem + } + newName = strings.TrimSpace(newName) + if err := validateMailboxName(newName); err != nil { + return err + } + if strings.EqualFold(newName, "INBOX") || isSystemMailboxName(newName) { + return store.ErrMailboxInvalid + } + if _, err := s.stores.Mailboxes.GetByName(userID, newName); err == nil { + return store.ErrMailboxExists + } + return s.stores.Mailboxes.Rename(userID, oldName, newName) +} + +// SetSubscribed 更新文件夹订阅状态(LSUB 过滤用)。 +func (s *MailboxService) SetSubscribed(userID uint, name string, subscribed bool) error { + name, ok := s.Canonical(userID, name) + if !ok { + return store.ErrMailboxNotFound + } + return s.stores.Mailboxes.SetSubscribed(userID, name, subscribed) +} + +// Move 把多封邮件移动到目标文件夹(web 删除=移入 Trash、恢复等共用)。 +// 不属于该用户的邮件被跳过。 +func (s *MailboxService) Move(userID uint, msgIDs []uint, dest string) error { + if len(msgIDs) == 0 { + return nil + } + if _, ok := s.Canonical(userID, dest); !ok { + return store.ErrMailboxNotFound + } + for _, id := range msgIDs { + msg, err := s.stores.Mails.GetByID(id) + if err != nil || msg.UserID != userID { + continue + } + if err := s.stores.Mails.MoveToFolder(id, dest); err != nil { + return err + } + } + return nil +} + +// Purge 永久删除文件夹中的邮件;msgIDs 为空时删除全部。 +// 返回被删除前的邮件列表(调用方据此推送 EXPUNGE)。 +func (s *MailboxService) Purge(userID uint, name string, msgIDs []uint) ([]db.Message, error) { + var msgs []db.Message + var err error + if len(msgIDs) == 0 { + msgs, err = s.stores.Mails.ListAllByUserAndFolder(userID, name) + if err != nil { + return nil, err + } + msgIDs = make([]uint, 0, len(msgs)) + for i := range msgs { + msgIDs = append(msgIDs, msgs[i].ID) + } + } else { + for _, id := range msgIDs { + msg, err := s.stores.Mails.GetByID(id) + if err != nil || msg.UserID != userID || msg.Folder != name { + continue + } + msgs = append(msgs, *msg) + } + } + if len(msgIDs) == 0 { + return nil, nil + } + if err := s.stores.Mails.DeleteMany(msgIDs); err != nil { + return nil, err + } + return msgs, nil +} + +// 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 +} + +// mailboxAttrs 把 SpecialUse 映射为 RFC 6154 的 IMAP 属性。 +func mailboxAttrs(mb db.Mailbox) []imap.MailboxAttr { + switch mb.SpecialUse { + case "Sent": + return []imap.MailboxAttr{imap.MailboxAttrSent} + case "Drafts": + return []imap.MailboxAttr{imap.MailboxAttrDrafts} + case "Trash": + return []imap.MailboxAttr{imap.MailboxAttrTrash} + default: + return nil + } +} diff --git a/internal/imap_server/session.go b/internal/imap_server/session.go index 2e493e9..899794d 100644 --- a/internal/imap_server/session.go +++ b/internal/imap_server/session.go @@ -279,17 +279,6 @@ func (s *imapSession) recordLogin(ip, username string, success bool, failReason, return entry.ID } -// systemMailboxes 是系统支持的全部文件夹。 -var systemMailboxes = []struct { - name string - attrs []imap.MailboxAttr -}{ - {"INBOX", nil}, - {"Sent", []imap.MailboxAttr{imap.MailboxAttrSent}}, - {"Drafts", []imap.MailboxAttr{imap.MailboxAttrDrafts}}, - {"Trash", []imap.MailboxAttr{imap.MailboxAttrTrash}}, -} - // matchPattern 按 RFC 3501 LIST wildcard 语义匹配(* 任意、% 不跨分隔符)。 // INBOX 大小写不敏感,其余文件夹按系统定义大小写精确匹配。 func matchPattern(name, pattern string) bool { @@ -326,16 +315,24 @@ func matchAnyPattern(name string, patterns []string) bool { return false } -// List 返回系统文件夹列表(LSUB 同样返回全部——订阅状态恒为已订阅)。 +// List 返回用户文件夹列表(数据源为 mailboxes 表,与 Web 侧边栏同源)。 +// LSUB(options.SelectSubscribed)只返回已订阅文件夹。 func (s *imapSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { - for _, mb := range systemMailboxes { - if !matchAnyPattern(mb.name, patterns) { + mbs, err := s.srv.svc.ListAll(s.currentUserID()) + if err != nil { + return err + } + for _, mb := range mbs { + if options.SelectSubscribed && !mb.IsSubscribed { + continue + } + if !matchAnyPattern(mb.Name, patterns) { continue } data := &imap.ListData{ - Attrs: mb.attrs, + Attrs: mailboxAttrs(mb), Delim: '/', - Mailbox: mb.name, + Mailbox: mb.Name, } if err := w.WriteList(data); err != nil { return err @@ -344,60 +341,52 @@ func (s *imapSession) List(w *imapserver.ListWriter, ref string, patterns []stri return nil } -// Create 不支持。 +// Create 创建自定义文件夹(已存在时返回 NO)。 func (s *imapSession) Create(mailbox string, options *imap.CreateOptions) error { - return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "mailbox creation not supported"} + return imapNoResponse(s.srv.svc.Create(s.currentUserID(), mailbox)) } -// Delete 不支持。 +// Delete 删除空的自定义文件夹(系统文件夹拒绝)。 func (s *imapSession) Delete(mailbox string) error { - return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "mailbox deletion not supported"} + return imapNoResponse(s.srv.svc.Delete(s.currentUserID(), mailbox)) } -// Rename 不支持。 +// Rename 重命名自定义文件夹(系统文件夹拒绝)。 func (s *imapSession) Rename(mailbox, newName string, options *imap.RenameOptions) error { - return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "mailbox rename not supported"} + return imapNoResponse(s.srv.svc.Rename(s.currentUserID(), mailbox, newName)) } -// Subscribe 恒为已订阅(no-op)。 +// Subscribe 订阅文件夹(影响 LSUB 过滤)。 func (s *imapSession) Subscribe(mailbox string) error { - return nil + return imapNoResponse(s.srv.svc.SetSubscribed(s.currentUserID(), mailbox, true)) } -// Unsubscribe no-op。 +// Unsubscribe 退订文件夹。 func (s *imapSession) Unsubscribe(mailbox string) error { - return nil + return imapNoResponse(s.srv.svc.SetSubscribed(s.currentUserID(), mailbox, false)) +} + +// imapNoResponse 把服务层错误映射为 IMAP NO 响应。 +func imapNoResponse(err error) error { + if err == nil { + return nil + } + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: err.Error()} } // ---------- 选中状态 ---------- // Select 选中邮箱:登记 mailboxHub 会话,返回状态数据。 func (s *imapSession) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) { - name, ok := canonicalMailboxName(mailbox) + name, ok := s.canonicalMailboxName(mailbox) if !ok { return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No such mailbox"} } - userID := s.currentUserID() - msgs, err := s.srv.stores.Mails.ListAllByUserAndFolder(userID, name) + data, err := s.srv.svc.Select(s.currentUserID(), name) if err != nil { return nil, err } - var unseen uint32 - for i := range msgs { - if !msgs[i].IsRead { - unseen++ - } - } - maxID, err := s.srv.stores.Mails.MaxIDByUserAndFolder(userID, name) - if err != nil { - return nil, err - } - uidValidity, err := s.srv.stores.MailboxState.UidValidity(userID, name) - if err != nil { - log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", name, err) - uidValidity = 1 - } // 换绑推送(若此前已选中,先注销旧邮箱) s.mu.Lock() @@ -416,15 +405,7 @@ func (s *imapSession) Select(mailbox string, options *imap.SelectOptions) (*imap s.hub = hub s.mu.Unlock() - flags := []imap.Flag{imap.FlagAnswered, imap.FlagFlagged, imap.FlagDeleted, imap.FlagSeen, imap.FlagDraft} - return &imap.SelectData{ - Flags: flags, - PermanentFlags: append(flags, imap.FlagWildcard), - NumMessages: uint32(len(msgs)), - NumRecent: 0, - UIDNext: imap.UID(maxID + 1), - UIDValidity: uidValidity, - }, nil + return data, nil } // Unselect 取消选中:注销 mailboxHub 会话。 @@ -444,63 +425,11 @@ func (s *imapSession) Unselect() error { // Status 返回邮箱状态(计数/未读/UIDNEXT/UIDVALIDITY/已删除标记数/大小)。 func (s *imapSession) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) { - name, ok := canonicalMailboxName(mailbox) + name, ok := s.canonicalMailboxName(mailbox) if !ok { return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No such mailbox"} } - userID := s.currentUserID() - - msgs, err := s.srv.stores.Mails.ListAllByUserAndFolder(userID, name) - if err != nil { - return nil, err - } - data := &imap.StatusData{Mailbox: name} - - if options.NumMessages || options.NumUnseen || options.NumDeleted || options.Size { - var unseen, deleted uint32 - var size int64 - for i := range msgs { - if !msgs[i].IsRead { - unseen++ - } - if msgs[i].IsDeleted { - deleted++ - } - size += int64(len(messageRawData(&msgs[i]))) - } - if options.NumMessages { - n := uint32(len(msgs)) - data.NumMessages = &n - } - if options.NumUnseen { - data.NumUnseen = &unseen - } - if options.NumDeleted { - data.NumDeleted = &deleted - } - if options.Size { - data.Size = &size - } - } - if options.NumRecent { - zero := uint32(0) - data.NumRecent = &zero - } - if options.UIDNext { - maxID, err := s.srv.stores.Mails.MaxIDByUserAndFolder(userID, name) - if err != nil { - return nil, err - } - data.UIDNext = imap.UID(maxID + 1) - } - if options.UIDValidity { - uidValidity, err := s.srv.stores.MailboxState.UidValidity(userID, name) - if err != nil { - return nil, err - } - data.UIDValidity = uidValidity - } - return data, nil + return s.srv.svc.Status(s.currentUserID(), name, options) } // ---------- 消息操作 ---------- @@ -873,7 +802,7 @@ func (s *imapSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags // Copy 把匹配消息复制到目标文件夹。 func (s *imapSession) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) { - destName, ok := canonicalMailboxName(dest) + destName, ok := s.canonicalMailboxName(dest) if !ok { return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No such mailbox"} } @@ -944,7 +873,7 @@ func (s *imapSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest st if s.isReadOnly() { return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Mailbox is read-only"} } - destName, ok := canonicalMailboxName(dest) + destName, ok := s.canonicalMailboxName(dest) if !ok { return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No such mailbox"} } @@ -1072,7 +1001,7 @@ func (s *imapSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) er // Append 追加一封新邮件(IMAP APPEND)。 func (s *imapSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { - name, ok := canonicalMailboxName(mailbox) + name, ok := s.canonicalMailboxName(mailbox) if !ok { return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No such mailbox"} } @@ -1235,20 +1164,10 @@ func ptrU32(v uint32) *uint32 { // ---------- Helper functions ---------- -// canonicalMailboxName 规范化邮箱名。 -func canonicalMailboxName(name string) (string, bool) { - switch strings.ToUpper(strings.TrimSpace(name)) { - case "INBOX": - return "INBOX", true - case "SENT": - return "Sent", true - case "DRAFTS": - return "Drafts", true - case "TRASH": - return "Trash", true - default: - return "", false - } +// canonicalMailboxName 规范化邮箱名(走 MailboxService:INBOX 大小写 +// 不敏感,其余按 mailboxes 表中实际名称匹配)。 +func (s *imapSession) canonicalMailboxName(name string) (string, bool) { + return s.srv.svc.Canonical(s.currentUserID(), name) } // flagsOf 按数据库状态生成 IMAP 标志列表。 diff --git a/internal/store/mailbox_store.go b/internal/store/mailbox_store.go new file mode 100644 index 0000000..a398400 --- /dev/null +++ b/internal/store/mailbox_store.go @@ -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 +} diff --git a/internal/store/mailbox_store_test.go b/internal/store/mailbox_store_test.go new file mode 100644 index 0000000..b28416b --- /dev/null +++ b/internal/store/mailbox_store_test.go @@ -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") + } +} diff --git a/internal/store/stores.go b/internal/store/stores.go index 9831069..de09ef7 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -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{} diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index 8637128..6b3cbd4 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -51,38 +51,88 @@ type MailHandler struct { stores *store.Stores storage *storage.AttachmentStorage outbound *outbound.Manager + // svc 邮箱服务层(IMAP 层共用):文件夹目录与消息操作同源。 + svc *imap_server.MailboxService // pusher 邮件状态变化推送(IMAP 客户端实时同步),可空 pusher imap_server.Pusher } // NewMailHandler creates a new MailHandler with the given stores, attachment -// storage and outbound delivery manager. -func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, pusher imap_server.Pusher) *MailHandler { - return &MailHandler{stores: stores, storage: attStorage, outbound: ob, pusher: pusher} +// storage, mailbox service and outbound delivery manager. +func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, svc *imap_server.MailboxService, pusher imap_server.Pusher) *MailHandler { + return &MailHandler{stores: stores, storage: attStorage, outbound: ob, svc: svc, pusher: pusher} } -// folderCounts returns sidebar badge counts for the current user. -func (h *MailHandler) folderCounts(userID uint) (inboxUnread, draftsTotal, sentTotal int64) { - inboxUnread, _ = h.stores.Mails.CountUnread(userID, "INBOX") - draftsTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Drafts") - sentTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Sent") - return -} - -// Inbox renders the inbox page showing all messages in the user's INBOX folder. -func (h *MailHandler) Inbox(c *gin.Context) { - userID := c.GetUint("userID") - page := getPageParam(c, "page", 1) - - messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20) +// foldersFor 返回当前用户的侧边栏文件夹列表(与 IMAP LIST 同源: +// IMAP 返回什么文件夹,Web 就显示什么)。 +func (h *MailHandler) foldersFor(userID uint) []imap_server.FolderInfo { + infos, err := h.svc.List(userID) if err != nil { - c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err) + log.Printf("web: 加载文件夹列表失败 user=%d: %v", userID, err) + return nil + } + return infos +} + +// userEmailOf 从 context 取当前用户完整邮箱(推送用),失败返回空串。 +func userEmailOf(c *gin.Context) string { + if cu, ok := c.Get("currentUser"); ok { + if u, ok := cu.(*db.User); ok { + return u.Username + "@" + u.Domain.Name + } + } + return "" +} + +// seqOfFolder 返回消息在文件夹中的序号(1 基,与 IMAP 序号排序一致)。 +func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32 { + msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, folder) + if err != nil { + return 0 + } + for i := range msgs { + if msgs[i].ID == msgID { + return uint32(i + 1) + } + } + return 0 +} + +// purgeMessages 永久删除邮件(含附件文件与配额回退)。 +func (h *MailHandler) purgeMessages(userID uint, msgs []db.Message) { + ids := make([]uint, 0, len(msgs)) + for i := range msgs { + attachments, _ := h.stores.Attachments.ListByMessage(msgs[i].ID) + for _, att := range attachments { + _ = h.storage.Delete(att.FilePath) + _ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize) + } + if err := h.stores.Attachments.DeleteByMessage(msgs[i].ID); err != nil { + log.Printf("web: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err) + } + ids = append(ids, msgs[i].ID) + } + if err := h.stores.Mails.DeleteMany(ids); err != nil { + log.Printf("web: 删除邮件失败: %v", err) + } +} + +// Folder renders the generic mailbox page for any folder the IMAP layer +// exposes (INBOX / Sent / Drafts / Trash / custom mailboxes). +func (h *MailHandler) Folder(c *gin.Context) { + userID := c.GetUint("userID") + name, ok := h.svc.Canonical(userID, c.Param("name")) + if !ok { + c.String(http.StatusNotFound, "邮箱不存在") return } + page := getPageParam(c, "page", 1) - inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) - - currentUser, _ := c.Get("currentUser") + messages, total, err := h.svc.Messages(userID, name, page, 20) + if err != nil { + c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err) + return + } totalPages := int(total) / 20 if int(total)%20 > 0 { @@ -92,18 +142,18 @@ func (h *MailHandler) Inbox(c *gin.Context) { totalPages = 0 } - c.HTML(200, "inbox", gin.H{ + currentUser, _ := c.Get("currentUser") + c.HTML(200, "folder", gin.H{ "currentUser": currentUser, "messages": messages, "total": total, "page": page, "pageSize": 20, "totalPages": totalPages, - "folder": "INBOX", - "activeFolder": "inbox", - "inboxUnread": inboxUnread, - "draftsTotal": draftsTotal, - "sentTotal": sentTotal, + "folder": name, + "activeFolder": name, + "isTrash": name == "Trash", + "folders": h.foldersFor(userID), }) } @@ -140,16 +190,14 @@ func (h *MailHandler) View(c *gin.Context) { } currentUser, _ := c.Get("currentUser") - inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) c.HTML(200, "view", gin.H{ "currentUser": currentUser, "message": msg, "attachments": attachments, - "activeFolder": resolveActiveFolder(msg.Folder), - "inboxUnread": inboxUnread, - "draftsTotal": draftsTotal, - "sentTotal": sentTotal, + "activeFolder": msg.Folder, + "inTrash": msg.Folder == "Trash", + "folders": h.foldersFor(userID), }) } @@ -167,8 +215,6 @@ func (h *MailHandler) Compose(c *gin.Context) { quotaBytes = user.QuotaBytes } - inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) - c.HTML(200, "compose", gin.H{ "currentUser": currentUser, "activeFolder": "compose", @@ -178,12 +224,26 @@ func (h *MailHandler) Compose(c *gin.Context) { "bodyContent": "", "usedBytes": usedBytes, "quotaBytes": quotaBytes, - "inboxUnread": inboxUnread, - "draftsTotal": draftsTotal, - "sentTotal": sentTotal, + "folders": h.foldersFor(userID), }) } +// composeData builds the shared template context for the compose page. +func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H { + return gin.H{ + "currentUser": user, + "activeFolder": "compose", + "error": errMsg, + "to": to, + "subject": subject, + "cc": cc, + "bodyContent": body, + "usedBytes": user.UsedBytes, + "quotaBytes": user.QuotaBytes, + "folders": h.foldersFor(userID), + } +} + // DoSend processes the email composition form, sends the email via SMTP, // and stores the message record. func (h *MailHandler) DoSend(c *gin.Context) { @@ -198,17 +258,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { cc := c.PostForm("cc") if to == "" { - c.HTML(http.StatusBadRequest, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": "请输入收件人", - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody)) return } @@ -226,17 +276,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { } reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize) if err != nil { - c.HTML(http.StatusInternalServerError, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": "配额检查失败,请稍后重试", - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody)) return } if !reserved { @@ -255,6 +295,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { "bodyContent": htmlBody, "usedBytes": usedBytes, "quotaBytes": quotaBytes, + "folders": h.foldersFor(userID), }) return } @@ -314,46 +355,16 @@ func (h *MailHandler) DoSend(c *gin.Context) { if len(externalRecipients) > 0 { ob := h.outbound if ob == nil || !ob.Enabled() { - c.HTML(http.StatusBadRequest, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": "外部投递未启用", - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody)) return } if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt { - c.HTML(http.StatusBadRequest, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody)) return } for _, rcpt := range externalRecipients { if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil { - c.HTML(http.StatusBadRequest, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody)) return } } @@ -375,17 +386,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { IsRead: false, } if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil { - c.HTML(http.StatusInternalServerError, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": fmt.Sprintf("投递邮件失败: %v", createErr), - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody)) return } // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) @@ -411,17 +412,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { } if createErr := h.stores.Mails.Create(msg); createErr != nil { - c.HTML(http.StatusInternalServerError, "compose", gin.H{ - "currentUser": currentUser, - "activeFolder": "compose", - "error": fmt.Sprintf("保存邮件失败: %v", createErr), - "to": to, - "subject": subject, - "cc": cc, - "bodyContent": htmlBody, - "usedBytes": currentUser.UsedBytes, - "quotaBytes": currentUser.QuotaBytes, - }) + c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody)) return } @@ -572,43 +563,6 @@ var mimeTypes = map[string]string{ ".csv": "text/csv", } -// Sent renders the sent mail folder page. -func (h *MailHandler) Sent(c *gin.Context) { - userID := c.GetUint("userID") - page := getPageParam(c, "page", 1) - - messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20) - if err != nil { - c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err) - return - } - - currentUser, _ := c.Get("currentUser") - inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) - - totalPages := int(total) / 20 - if int(total)%20 > 0 { - totalPages++ - } - if totalPages < 1 { - totalPages = 0 - } - - c.HTML(200, "sent", gin.H{ - "currentUser": currentUser, - "messages": messages, - "total": total, - "page": page, - "pageSize": 20, - "totalPages": totalPages, - "folder": "Sent", - "activeFolder": "sent", - "inboxUnread": inboxUnread, - "draftsTotal": draftsTotal, - "sentTotal": sentTotal, - }) -} - // safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //), // 防止把用户重定向到外部站点(开放重定向)。非法值返回空串, // 调用方应回退到默认路径。 @@ -619,7 +573,8 @@ func safeRedirectPath(referer string) string { return referer } -// Delete removes a message by ID after verifying ownership. +// Delete 删除邮件(IMAP 语义):非 Trash 文件夹 → 移入 Trash; +// 已在 Trash → 彻底删除。 func (h *MailHandler) Delete(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) @@ -633,50 +588,128 @@ func (h *MailHandler) Delete(c *gin.Context) { c.String(http.StatusForbidden, "禁止访问") return } + userEmail := userEmailOf(c) - // Delete attachments on disk and in DB, and decrease UsedBytes - attachments, _ := h.stores.Attachments.ListByMessage(uint(id)) - for _, att := range attachments { - _ = h.storage.Delete(att.FilePath) - _ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize) - } - if err := h.stores.Attachments.DeleteByMessage(uint(id)); err != nil { - log.Printf("web: 删除附件记录失败 msg=%d: %v", id, err) - } - - // 删除前计算消息在所属文件夹中的序号(用于 Expunge 推送) - var seq uint32 - if msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, msg.Folder); err == nil { - for i := range msgs { - if msgs[i].ID == uint(id) { - seq = uint32(i + 1) - break - } + if msg.Folder == "Trash" { + // 垃圾箱中删除 = 彻底删除 + seq := h.seqOfFolder(userID, msg.Folder, msg.ID) + h.purgeMessages(userID, []db.Message{*msg}) + if h.pusher != nil && userEmail != "" { + h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq}) } - } - if err := h.stores.Mails.Delete(uint(id)); err != nil { - log.Printf("web: 删除邮件失败 msg=%d: %v", id, err) - } - - // 删除 → 推送给该用户的其他 IMAP 客户端 - if h.pusher != nil && seq > 0 { - userEmail := "" - if cu, ok := c.Get("currentUser"); ok { - if u, ok := cu.(*db.User); ok { - userEmail = u.Username + "@" + u.Domain.Name - } + } else { + // 其余文件夹删除 = 移入垃圾箱(与 IMAP MOVE 同源语义) + seq := h.seqOfFolder(userID, msg.Folder, msg.ID) + if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil { + log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err) + c.String(http.StatusInternalServerError, "删除失败") + return + } + if h.pusher != nil && userEmail != "" { + h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq}) + h.pusher.PushNewMessage(userEmail, &db.Message{UserID: userID, Folder: "Trash"}) } - h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq}) } // Redirect back based on the folder(仅同站相对路径,防开放重定向) referer := safeRedirectPath(c.GetHeader("Referer")) if referer == "" { - referer = "/inbox" + referer = "/folder/" + msg.Folder } c.Redirect(http.StatusFound, referer) } +// Restore 把垃圾箱中的邮件恢复到收件箱。 +func (h *MailHandler) Restore(c *gin.Context) { + userID := c.GetUint("userID") + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的邮件ID") + return + } + + msg, err := h.stores.Mails.GetByID(uint(id)) + if err != nil || msg.UserID != userID { + c.String(http.StatusForbidden, "禁止访问") + return + } + if msg.Folder != "Trash" { + c.Redirect(http.StatusFound, "/folder/"+msg.Folder) + return + } + + seq := h.seqOfFolder(userID, "Trash", msg.ID) + if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil { + log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err) + c.String(http.StatusInternalServerError, "恢复失败") + return + } + if h.pusher != nil { + if email := userEmailOf(c); email != "" { + h.pusher.PushExpunged(email, "Trash", []uint32{seq}) + h.pusher.PushNewMessage(email, &db.Message{UserID: userID, Folder: "INBOX"}) + } + } + c.Redirect(http.StatusFound, "/folder/Trash") +} + +// Purge 彻底删除一封邮件(任意文件夹)。 +func (h *MailHandler) Purge(c *gin.Context) { + userID := c.GetUint("userID") + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "无效的邮件ID") + return + } + + msg, err := h.stores.Mails.GetByID(uint(id)) + if err != nil || msg.UserID != userID { + c.String(http.StatusForbidden, "禁止访问") + return + } + + seq := h.seqOfFolder(userID, msg.Folder, msg.ID) + h.purgeMessages(userID, []db.Message{*msg}) + if h.pusher != nil { + if email := userEmailOf(c); email != "" { + h.pusher.PushExpunged(email, msg.Folder, []uint32{seq}) + } + } + + referer := safeRedirectPath(c.GetHeader("Referer")) + if referer == "" { + referer = "/folder/" + msg.Folder + } + c.Redirect(http.StatusFound, referer) +} + +// EmptyFolder 清空文件夹(永久删除其中全部邮件)。 +func (h *MailHandler) EmptyFolder(c *gin.Context) { + userID := c.GetUint("userID") + name, ok := h.svc.Canonical(userID, c.Param("name")) + if !ok { + c.String(http.StatusNotFound, "邮箱不存在") + return + } + + msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name) + if err != nil { + c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err) + return + } + seqs := make([]uint32, 0, len(msgs)) + for i := range msgs { + seqs = append(seqs, uint32(i+1)) + } + h.purgeMessages(userID, msgs) + if h.pusher != nil { + if email := userEmailOf(c); email != "" { + h.pusher.PushExpunged(email, name, seqs) + } + } + c.Redirect(http.StatusFound, "/folder/"+name) +} + // MarkRead marks a message as read. func (h *MailHandler) MarkRead(c *gin.Context) { userID := c.GetUint("userID") @@ -709,7 +742,7 @@ func (h *MailHandler) MarkRead(c *gin.Context) { // Redirect back based on the folder(仅同站相对路径,防开放重定向) referer := safeRedirectPath(c.GetHeader("Referer")) if referer == "" { - referer = "/inbox" + referer = "/folder/INBOX" } c.Redirect(http.StatusFound, referer) } @@ -760,60 +793,31 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int { return page } -// Drafts renders the drafts folder page. -func (h *MailHandler) Drafts(c *gin.Context) { - userID := c.GetUint("userID") - page := getPageParam(c, "page", 1) - - messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20) - if err != nil { - c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err) - return - } - - currentUser, _ := c.Get("currentUser") - inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) - - totalPages := int(total) / 20 - if int(total)%20 > 0 { - totalPages++ - } - if totalPages < 1 { - totalPages = 0 - } - - c.HTML(200, "drafts", gin.H{ - "currentUser": currentUser, - "messages": messages, - "total": total, - "page": page, - "pageSize": 20, - "totalPages": totalPages, - "folder": "Drafts", - "activeFolder": "drafts", - "inboxUnread": inboxUnread, - "draftsTotal": draftsTotal, - "sentTotal": sentTotal, - }) -} - // Settings renders the user settings page. func (h *MailHandler) Settings(c *gin.Context) { currentUser, _ := c.Get("currentUser") userID := c.GetUint("userID") - inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) c.HTML(200, "settings", gin.H{ "currentUser": currentUser, "activeFolder": "settings", "error": "", "success": "", "mustChange": c.Query("force") == "1", - "inboxUnread": inboxUnread, - "draftsTotal": draftsTotal, - "sentTotal": sentTotal, + "folders": h.foldersFor(userID), }) } +// settingsData builds the shared template context for the settings page. +func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H { + return gin.H{ + "currentUser": user, + "activeFolder": "settings", + "error": errMsg, + "success": success, + "folders": h.foldersFor(userID), + } +} + // UpdateSettings handles the password change form. func (h *MailHandler) UpdateSettings(c *gin.Context) { userID := c.GetUint("userID") @@ -826,76 +830,32 @@ func (h *MailHandler) UpdateSettings(c *gin.Context) { // Verify old password if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil { - c.HTML(http.StatusBadRequest, "settings", gin.H{ - "currentUser": currentUser, - "activeFolder": "settings", - "error": "当前密码不正确", - "success": "", - }) + c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", "")) return } if newPassword == "" { - c.HTML(http.StatusBadRequest, "settings", gin.H{ - "currentUser": currentUser, - "activeFolder": "settings", - "error": "新密码不能为空", - "success": "", - }) + c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", "")) return } if newPassword != confirmPassword { - c.HTML(http.StatusBadRequest, "settings", gin.H{ - "currentUser": currentUser, - "activeFolder": "settings", - "error": "两次输入的密码不一致", - "success": "", - }) + c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", "")) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { - c.HTML(http.StatusInternalServerError, "settings", gin.H{ - "currentUser": currentUser, - "activeFolder": "settings", - "error": "密码加密失败", - "success": "", - }) + c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", "")) return } if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil { - c.HTML(http.StatusInternalServerError, "settings", gin.H{ - "currentUser": currentUser, - "activeFolder": "settings", - "error": "密码更新失败", - "success": "", - }) + c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", "")) return } - c.HTML(http.StatusOK, "settings", gin.H{ - "currentUser": currentUser, - "activeFolder": "settings", - "error": "", - "success": "密码修改成功", - }) -} - -// resolveActiveFolder maps a folder name to a sidebar active state key. -func resolveActiveFolder(folder string) string { - switch folder { - case "INBOX": - return "inbox" - case "Sent": - return "sent" - case "Drafts": - return "drafts" - default: - return folder - } + c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功")) } // formatBytes converts a file size in bytes to a human-readable string. diff --git a/internal/web/handlers/mail_delete_test.go b/internal/web/handlers/mail_delete_test.go new file mode 100644 index 0000000..d9a696e --- /dev/null +++ b/internal/web/handlers/mail_delete_test.go @@ -0,0 +1,188 @@ +package handlers + +// Web 删除语义回归测试:删除=移入垃圾箱(IMAP 语义)、垃圾箱中删除=彻底 +// 删除、恢复=回到收件箱、清空=永久删除。文件夹操作全部经由 MailboxService +// (与 IMAP 会话共用同一份实现)。 + +import ( + "html/template" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "mail_go/internal/db" + "mail_go/internal/imap_server" + "mail_go/internal/store" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newMailTestHandler(t *testing.T) (*MailHandler, *store.Stores) { + t.Helper() + gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil { + t.Fatal(err) + } + stores := store.NewStores(gdb) + if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1}); err != nil { + t.Fatal(err) + } + return NewMailHandler(stores, nil, nil, imap_server.NewMailboxService(stores), nil), stores +} + +// newMailTestRouter 注册删除/恢复/清空路由并注入认证上下文。 +func newMailTestRouter(h *MailHandler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + // Folder 页渲染需要模板(与 oauth2 测试共用同一份测试函数表) + tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html"))) + r.SetHTMLTemplate(tmpl) + r.Use(func(c *gin.Context) { + c.Set("userID", uint(1)) + c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}}) + c.Next() + }) + r.POST("/mail/delete/:id", h.Delete) + r.POST("/mail/restore/:id", h.Restore) + r.POST("/mail/purge/:id", h.Purge) + r.POST("/folder/:name/empty", h.EmptyFolder) + r.GET("/folder/:name", h.Folder) + return r +} + +func seedWebMsg(t *testing.T, stores *store.Stores, folder string) *db.Message { + t.Helper() + msg := &db.Message{ + UserID: 1, + Folder: folder, + FromAddr: "sender@other.com", + ToAddr: "alice@example.com", + Subject: "测试邮件", + Date: time.Now(), + } + if err := stores.Mails.Create(msg); err != nil { + t.Fatal(err) + } + return msg +} + +func msgFolder(t *testing.T, stores *store.Stores, id uint) (string, bool) { + t.Helper() + msg, err := stores.Mails.GetByID(id) + if err != nil { + return "", false + } + return msg.Folder, true +} + +func TestWebDeleteMovesToTrash(t *testing.T) { + h, stores := newMailTestHandler(t) + msg := seedWebMsg(t, stores, "INBOX") + r := newMailTestRouter(h) + + // 收件箱删除 → 移入垃圾箱(非物理删除) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil) + req.Header.Set("Referer", "/folder/INBOX") + r.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("delete status = %d, want 302", w.Code) + } + folder, ok := msgFolder(t, stores, msg.ID) + if !ok || folder != "Trash" { + t.Fatalf("deleted msg folder = %q, want Trash", folder) + } + + // 垃圾箱中再删除 → 彻底删除 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil) + req2.Header.Set("Referer", "/folder/Trash") + r.ServeHTTP(w2, req2) + if _, ok := msgFolder(t, stores, msg.ID); ok { + t.Fatal("trash delete should purge the message") + } +} + +func TestWebRestoreToInbox(t *testing.T) { + h, stores := newMailTestHandler(t) + msg := seedWebMsg(t, stores, "Trash") + r := newMailTestRouter(h) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mail/restore/"+itoa(msg.ID), nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("restore status = %d, want 302", w.Code) + } + folder, ok := msgFolder(t, stores, msg.ID) + if !ok || folder != "INBOX" { + t.Fatalf("restored msg folder = %q, want INBOX", folder) + } +} + +func TestWebEmptyFolderPurgesAll(t *testing.T) { + h, stores := newMailTestHandler(t) + seedWebMsg(t, stores, "Trash") + seedWebMsg(t, stores, "Trash") + r := newMailTestRouter(h) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/folder/Trash/empty", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("empty status = %d, want 302", w.Code) + } + count, err := stores.Mails.CountByUserAndFolder(1, "Trash") + if err != nil || count != 0 { + t.Fatalf("Trash count after empty = %d, want 0", count) + } +} + +func TestWebFolderPageListsDynamicFolders(t *testing.T) { + h, stores := newMailTestHandler(t) + seedWebMsg(t, stores, "INBOX") + r := newMailTestRouter(h) + + // IMAP CREATE 语义创建的自定义文件夹(经同一 MailboxService) + svc := imap_server.NewMailboxService(stores) + if err := svc.Create(1, "工作"); err != nil { + t.Fatalf("create custom mailbox: %v", err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/folder/工作", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("custom folder page status = %d, want 200", w.Code) + } + + // 不存在的文件夹 → 404 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/folder/不存在", nil) + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusNotFound { + t.Fatalf("missing folder status = %d, want 404", w2.Code) + } +} + +func itoa(n uint) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/internal/web/handlers/oauth2_state_test.go b/internal/web/handlers/oauth2_state_test.go index 0710059..6c56b89 100644 --- a/internal/web/handlers/oauth2_state_test.go +++ b/internal/web/handlers/oauth2_state_test.go @@ -46,7 +46,10 @@ func testTemplateFuncs() template.FuncMap { "initial": func(s string) string { return "?" }, "truncate": func(s string, n int) string { return s }, "shortDate": func(t time.Time) string { return t.Format("2006-01-02") }, + "localTime": func(t time.Time) time.Time { return t }, "avatarStyle": func(s string) string { return "background:#eee;color:#333" }, + "urlPath": func(s string) string { return url.PathEscape(s) }, + "folderLabel": func(s string) string { return s }, } } diff --git a/internal/web/render_test.go b/internal/web/render_test.go index e80867d..37391f8 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -13,6 +13,7 @@ import ( "time" "mail_go/internal/db" + "mail_go/internal/imap_server" ) func TestRenderAllPages(t *testing.T) { @@ -46,27 +47,34 @@ func TestRenderAllPages(t *testing.T) { {ID: 2, FileName: "logo.png", FileSize: 128 * 1024}, } + folders := []imap_server.FolderInfo{ + {Name: "INBOX", SpecialUse: "", Unseen: 2}, + {Name: "Sent", SpecialUse: "Sent", Total: 3}, + {Name: "Drafts", SpecialUse: "Drafts", Total: 1}, + {Name: "Trash", SpecialUse: "Trash", Total: 5}, + {Name: "工作", SpecialUse: "", Total: 4}, + } + cases := []struct { name string data ginH }{ {"login", ginH{"error": ""}}, {"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}}, - {"inbox", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "activeFolder": "inbox", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, - {"drafts", ginH{"currentUser": user, "messages": messages, "total": 1, "page": 1, "totalPages": 1, "activeFolder": "drafts", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, - {"sent", ginH{"currentUser": user, "messages": messages, "total": 3, "page": 1, "totalPages": 1, "activeFolder": "sent", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, + {"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}}, + {"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}}, {"view", ginH{ - "currentUser": user, "activeFolder": "inbox", + "currentUser": user, "activeFolder": "INBOX", "message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= ", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false}, - "attachments": attachments, "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3), + "attachments": attachments, "inTrash": false, "folders": folders, }}, {"compose", ginH{ "currentUser": user, "activeFolder": "compose", "error": "", "to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "", "usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024), - "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3), + "folders": folders, }}, - {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, + {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}}, {"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, {"admin_bans", ginH{ "currentUser": user, "activeFolder": "bans", diff --git a/internal/web/server.go b/internal/web/server.go index b1bcb92..2e57ec4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -7,6 +7,7 @@ import ( "math" "net" "net/http" + "net/url" "os" "strconv" "path/filepath" @@ -110,6 +111,23 @@ func templateFuncs() template.FuncMap { "localTime": localTime, // avatarStyle 根据字符串哈希生成头像背景/前景色。 "avatarStyle": avatarStyle, + // urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。 + "urlPath": url.PathEscape, + // folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。 + "folderLabel": func(name string) string { + switch name { + case "INBOX": + return "收件箱" + case "Sent": + return "已发送" + case "Drafts": + return "草稿箱" + case "Trash": + return "已删除" + default: + return name + } + }, } } @@ -294,7 +312,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag // registerRoutes sets up all HTTP routes with their handlers and middleware. func (ws *WebServer) registerRoutes() { authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg) - mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.pusher) + mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, imap_server.NewMailboxService(ws.stores), ws.pusher) adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub) // Apply BanMiddleware globally before public routes @@ -318,20 +336,27 @@ func (ws *WebServer) registerRoutes() { c.Redirect(302, "/inbox") }) - // Mail routes - auth.GET("/inbox", mailHandler.Inbox) - auth.GET("/inbox/:id", mailHandler.View) + // Mail routes:通用文件夹页(文件夹目录与 IMAP LIST 同源) + auth.GET("/folder/:name", mailHandler.Folder) + auth.GET("/folder/:name/:id", mailHandler.View) + auth.POST("/folder/:name/empty", mailHandler.EmptyFolder) auth.GET("/compose", mailHandler.Compose) auth.POST("/compose", mailHandler.DoSend) - auth.GET("/drafts", mailHandler.Drafts) - auth.GET("/drafts/:id", mailHandler.View) - auth.GET("/sent", mailHandler.Sent) - auth.GET("/sent/:id", mailHandler.View) auth.GET("/settings", mailHandler.Settings) auth.POST("/settings", mailHandler.UpdateSettings) auth.POST("/mail/delete/:id", mailHandler.Delete) + auth.POST("/mail/restore/:id", mailHandler.Restore) + auth.POST("/mail/purge/:id", mailHandler.Purge) auth.POST("/mail/read/:id", mailHandler.MarkRead) auth.GET("/attachment/:id", mailHandler.DownloadAttachment) + + // 旧路径兼容重定向(登录跳转、书签、外部链接仍指向 /inbox 等) + auth.GET("/inbox", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX") }) + auth.GET("/inbox/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX/"+c.Param("id")) }) + auth.GET("/sent", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent") }) + auth.GET("/sent/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent/"+c.Param("id")) }) + auth.GET("/drafts", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts") }) + auth.GET("/drafts/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts/"+c.Param("id")) }) } // Admin routes (auth + admin required) diff --git a/internal/web/session_secret_test.go b/internal/web/session_secret_test.go index e941abf..3422c62 100644 --- a/internal/web/session_secret_test.go +++ b/internal/web/session_secret_test.go @@ -41,7 +41,7 @@ func newTestStores(t *testing.T) *store.Stores { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil { + if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.Mailbox{}); err != nil { t.Fatalf("migrate: %v", err) } return store.NewStores(gdb) @@ -122,7 +122,7 @@ func TestSessionSignedWithConfiguredSecretKey(t *testing.T) { } // 合法会话可以访问收件箱 - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) + req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie}) resp2, err := client.Do(req) if err != nil { @@ -151,7 +151,7 @@ func TestLegacyHardcodedKeyCannotForgeSession(t *testing.T) { t.Fatalf("forge cookie: %v", err) } - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) + req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged}) client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -229,7 +229,7 @@ func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) { expired := time.Now().Add(-8 * 24 * time.Hour).Unix() cookie := encodeSessionCookie(t, key, authCookieValues(1, expired)) - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) + req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie}) client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -254,7 +254,7 @@ func TestSessionWithinExpiryWorks(t *testing.T) { cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix())) - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil) + req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil) req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie}) client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index c781bb1..2389cb9 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -353,11 +353,12 @@ .folder { flex: 1; flex-direction: column; justify-content: center; gap: 2px; height: 46px; padding: 0; border-radius: 8px; font-size: 10.5px; - position: relative; + position: relative; order: 7; /* 自定义文件夹排在最后 */ } .folder-nav .folder:nth-child(1) { order: 1; } .folder-nav .folder:nth-child(2) { order: 2; } .folder-nav .folder:nth-child(3) { order: 4; } + .folder-nav .folder:nth-child(4) { order: 6; } .folder svg { width: 19px; height: 19px; } .folder.active::before { display: none; } .folder .badge { @@ -376,7 +377,7 @@ .compose-btn svg { width: 20px; height: 20px; } .sidebar-footer { margin: 0; border: none; padding: 0; - flex-direction: row; gap: 2px; order: 5; + flex-direction: row; gap: 2px; order: 8; } .mail-main { padding-bottom: calc(58px + env(safe-area-inset-bottom)); } @@ -463,21 +464,25 @@ 写信 {{if not .messages}}
-
📝草稿箱暂无邮件
+
{{if .isTrash}}🗑{{else}}📭{{end}}{{folderLabel .folder}}暂无邮件
{{else}} @@ -68,7 +94,7 @@ 第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页
{{if gt .page 1}} - + 上一页 @@ -76,7 +102,7 @@ 上一页 {{end}} {{if lt .page .totalPages}} - + 下一页 diff --git a/internal/web/templates/inbox.html b/internal/web/templates/inbox.html deleted file mode 100644 index bd53937..0000000 --- a/internal/web/templates/inbox.html +++ /dev/null @@ -1,88 +0,0 @@ -{{define "inbox"}} - - - - - - 收件箱 - MailGo - {{template "styles" .}} - - - {{template "navbar" .}} -
- {{template "sidebar" .}} -
-
- - - -
- 共 {{.total}} 封 -
- - {{if not .messages}} -
-
📭收件箱暂无邮件
-
- {{else}} -
    - {{range .messages}} -
  • - - - {{initial (mailName (decodeHeader .FromAddr))}} - - {{mailName (decodeHeader .FromAddr)}} - - {{if not .IsRead}}{{end}} - - {{if .Subject}}{{.Subject}}{{else}}(无主题){{end}} - - - - {{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}} - - {{shortDate .Date}} -
  • - {{end}} -
- {{end}} - - -
-
- {{template "listjs" .}} - - -{{end}} diff --git a/internal/web/templates/sent.html b/internal/web/templates/sent.html deleted file mode 100644 index 66dff0f..0000000 --- a/internal/web/templates/sent.html +++ /dev/null @@ -1,93 +0,0 @@ -{{define "sent"}} - - - - - - 已发送 - MailGo - {{template "styles" .}} - - - {{template "navbar" .}} -
- {{template "sidebar" .}} -
-
- - - -
- 共 {{.total}} 封 -
- - {{if not .messages}} -
-
📤已发送暂无邮件
-
- {{else}} - - {{end}} - - -
-
- {{template "listjs" .}} - - -{{end}} diff --git a/internal/web/templates/view.html b/internal/web/templates/view.html index 1d73b0b..621a82b 100644 --- a/internal/web/templates/view.html +++ b/internal/web/templates/view.html @@ -21,6 +21,21 @@ 回复 + {{if .inTrash}} +
+ +
+
+ +
+ {{else}}
+ {{end}}
@@ -74,6 +90,21 @@ 回复 + {{if .inTrash}} +
+ +
+
+ +
+ {{else}}
+ {{end}}