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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+43
-124
@@ -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 标志列表。
|
||||
|
||||
Reference in New Issue
Block a user