- 新增 mailboxes 表与 MailboxStore(系统文件夹幂等创建,自定义文件夹 CRUD) - 提取 MailboxService:IMAP 会话与 Web handler 共用,LIST 返回什么 Web 就显示什么 - IMAP 支持 CREATE/DELETE/RENAME/SUBSCRIBE(系统文件夹禁删改、非空禁删) - Web 删除改为 IMAP 语义:移入 Trash,新增恢复/彻底删除/清空 - 新增通用 /folder/:name 页面与动态侧边栏,/inbox /sent /drafts 保留兼容重定向
336 lines
9.2 KiB
Go
336 lines
9.2 KiB
Go
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
|
|
}
|
|
}
|