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

+123 -1
View File
@@ -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
}