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 changed files with 1440 additions and 639 deletions
+9 -7
View File
@@ -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/<name> |
| 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: 集成调试 + 安装脚本**
+1 -1
View File
@@ -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)
}
+33
View File
@@ -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 / TrashINBOX 为 ""
}
// 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 等)会据此丢弃本地缓存
+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
}
+1 -1
View File
@@ -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)
+14 -3
View File
@@ -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(本地投递为 INBOXWeb 移动/恢复时
// 用于通知目标文件夹,如 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)
}
}
+335
View File
@@ -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
View File
@@ -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 侧边栏同源)。
// LSUBoptions.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 规范化邮箱名(走 MailboxServiceINBOX 大小写
// 不敏感,其余按 mailboxes 表中实际名称匹配)。
func (s *imapSession) canonicalMailboxName(name string) (string, bool) {
return s.srv.svc.Canonical(s.currentUserID(), name)
}
// flagsOf 按数据库状态生成 IMAP 标志列表。
+169
View File
@@ -0,0 +1,169 @@
package store
import (
"errors"
"mail_go/internal/db"
"gorm.io/gorm"
)
// Mailbox store errors(英文文案:IMAP 响应直接使用,Web 侧另行提示)。
var (
ErrMailboxNotFound = errors.New("No such mailbox")
ErrMailboxExists = errors.New("mailbox already exists")
ErrMailboxInvalid = errors.New("invalid mailbox name")
ErrMailboxSystem = errors.New("system mailbox cannot be deleted or renamed")
ErrMailboxNotEmpty = errors.New("mailbox is not empty")
)
// MailboxStore defines the interface for mailbox (folder) operations.
type MailboxStore interface {
// EnsureSystem 幂等创建用户的系统文件夹(INBOX/Sent/Drafts/Trash)。
EnsureSystem(userID uint) error
// List 返回用户全部文件夹:系统文件夹按规范顺序在前,
// 自定义文件夹按名称升序在后。
List(userID uint) ([]db.Mailbox, error)
GetByName(userID uint, name string) (*db.Mailbox, error)
Create(mb *db.Mailbox) error
// Delete 删除空的自定义文件夹(含对应的 mailbox_states 记录)。
Delete(userID uint, name string) error
// Rename 重命名文件夹并同步迁移其邮件与 mailbox_states 记录。
Rename(userID uint, oldName, newName string) error
SetSubscribed(userID uint, name string, subscribed bool) error
}
// mailboxStoreGorm implements MailboxStore using GORM.
type mailboxStoreGorm struct {
db *gorm.DB
}
// newMailboxStore creates a new GORM-backed MailboxStore.
func newMailboxStore(database *gorm.DB) MailboxStore {
return &mailboxStoreGorm{db: database}
}
// isSystemMailboxName reports whether name is one of the system mailboxes.
func isSystemMailboxName(name string) bool {
for _, def := range db.SystemMailboxes {
if def.Name == name {
return true
}
}
return false
}
// EnsureSystem creates missing system mailboxes for the user.
// 已存在的行不覆盖:用户此前若已退订某系统文件夹(LSUB),状态保留。
func (s *mailboxStoreGorm) EnsureSystem(userID uint) error {
var existing []db.Mailbox
if err := s.db.Where("user_id = ?", userID).Find(&existing).Error; err != nil {
return err
}
have := make(map[string]bool, len(existing))
for _, mb := range existing {
have[mb.Name] = true
}
for _, def := range db.SystemMailboxes {
if have[def.Name] {
continue
}
mb := &db.Mailbox{
UserID: userID,
Name: def.Name,
SpecialUse: def.SpecialUse,
IsSubscribed: true,
}
if err := s.db.Create(mb).Error; err != nil {
return err
}
}
return nil
}
// List returns all folders for a user with system folders first.
func (s *mailboxStoreGorm) List(userID uint) ([]db.Mailbox, error) {
var mbs []db.Mailbox
if err := s.db.Where("user_id = ?", userID).Order("name").Find(&mbs).Error; err != nil {
return nil, err
}
byName := make(map[string]db.Mailbox, len(mbs))
for _, mb := range mbs {
byName[mb.Name] = mb
}
sys := make([]db.Mailbox, 0, len(db.SystemMailboxes))
for _, def := range db.SystemMailboxes {
if mb, ok := byName[def.Name]; ok {
sys = append(sys, mb)
}
}
custom := make([]db.Mailbox, 0, len(mbs))
for _, mb := range mbs {
if !isSystemMailboxName(mb.Name) {
custom = append(custom, mb)
}
}
return append(sys, custom...), nil
}
// GetByName returns a folder by its exact name.
func (s *mailboxStoreGorm) GetByName(userID uint, name string) (*db.Mailbox, error) {
var mb db.Mailbox
if err := s.db.Where("user_id = ? AND name = ?", userID, name).First(&mb).Error; err != nil {
return nil, err
}
return &mb, nil
}
// Create inserts a new mailbox row.
func (s *mailboxStoreGorm) Create(mb *db.Mailbox) error {
return s.db.Create(mb).Error
}
// Delete removes an empty mailbox and its mailbox_states record.
func (s *mailboxStoreGorm) Delete(userID uint, name string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, name).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrMailboxNotEmpty
}
if err := tx.Where("user_id = ? AND name = ?", userID, name).
Delete(&db.Mailbox{}).Error; err != nil {
return err
}
return tx.Where("user_id = ? AND folder = ?", userID, name).
Delete(&db.MailboxState{}).Error
})
}
// Rename renames a mailbox, moving its messages along with it.
func (s *mailboxStoreGorm) Rename(userID uint, oldName, newName string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&db.Mailbox{}).
Where("user_id = ? AND name = ?", userID, oldName).
Update("name", newName).Error; err != nil {
return err
}
if err := tx.Model(&db.Message{}).
Where("user_id = ? AND folder = ?", userID, oldName).
Update("folder", newName).Error; err != nil {
return err
}
// 旧 UIDVALIDITY 不再适用:删除状态记录,下次访问重新生成,
// 客户端据此丢弃缓存并全量重同步。
return tx.Where("user_id = ? AND folder = ?", userID, oldName).
Delete(&db.MailboxState{}).Error
})
}
// SetSubscribed updates the subscription flag of a mailbox.
func (s *mailboxStoreGorm) SetSubscribed(userID uint, name string, subscribed bool) error {
return s.db.Model(&db.Mailbox{}).
Where("user_id = ? AND name = ?", userID, name).
Update("is_subscribed", subscribed).Error
}
+141
View File
@@ -0,0 +1,141 @@
package store
import (
"testing"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// newMailboxTestStore 返回基于内存库的 MailboxStore(含 mailboxes 表)。
func newMailboxTestStore(t *testing.T) (MailboxStore, *gorm.DB) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.Mailbox{}, &db.Message{}, &db.MailboxState{}); err != nil {
t.Fatal(err)
}
return newMailboxStore(gdb), gdb
}
// TestMailboxEnsureSystem 验证系统文件夹幂等创建与规范排序。
func TestMailboxEnsureSystem(t *testing.T) {
s, _ := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatalf("EnsureSystem: %v", err)
}
// 幂等:再次调用不报错、不产生重复行
if err := s.EnsureSystem(1); err != nil {
t.Fatalf("EnsureSystem 2nd: %v", err)
}
mbs, err := s.List(1)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(mbs) != 4 {
t.Fatalf("len(List) = %d, want 4", len(mbs))
}
want := []string{"INBOX", "Sent", "Drafts", "Trash"}
for i, name := range want {
if mbs[i].Name != name {
t.Fatalf("List[%d] = %q, want %q", i, mbs[i].Name, name)
}
}
if mbs[3].SpecialUse != "Trash" {
t.Fatalf("Trash SpecialUse = %q, want Trash", mbs[3].SpecialUse)
}
}
// TestMailboxCustomFolderLifecycle 验证自定义文件夹:创建/排序/非空删除拒绝。
func TestMailboxCustomFolderLifecycle(t *testing.T) {
s, gdb := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.Create(&db.Mailbox{UserID: 1, Name: "工作", IsSubscribed: true}); err != nil {
t.Fatalf("Create: %v", err)
}
mbs, err := s.List(1)
if err != nil {
t.Fatal(err)
}
if len(mbs) != 5 || mbs[4].Name != "工作" {
t.Fatalf("custom folder not last: %+v", mbs)
}
// 放一封邮件进去 → 非空文件夹不可删除
msg := &db.Message{UserID: 1, Folder: "工作", FromAddr: "a@b", Subject: "s"}
if err := gdb.Create(msg).Error; err != nil {
t.Fatal(err)
}
if err := s.Delete(1, "工作"); err != ErrMailboxNotEmpty {
t.Fatalf("Delete non-empty mailbox = %v, want ErrMailboxNotEmpty", err)
}
// 清空后可删除
if err := gdb.Where("id = ?", msg.ID).Delete(&db.Message{}).Error; err != nil {
t.Fatal(err)
}
if err := s.Delete(1, "工作"); err != nil {
t.Fatalf("Delete empty mailbox: %v", err)
}
if _, err := s.GetByName(1, "工作"); err == nil {
t.Fatal("mailbox should be gone after Delete")
}
}
// TestMailboxRenameMovesMessages 验证重命名同步迁移邮件。
func TestMailboxRenameMovesMessages(t *testing.T) {
s, gdb := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.Create(&db.Mailbox{UserID: 1, Name: "旧名字", IsSubscribed: true}); err != nil {
t.Fatal(err)
}
msg := &db.Message{UserID: 1, Folder: "旧名字", FromAddr: "a@b", Subject: "s"}
if err := gdb.Create(msg).Error; err != nil {
t.Fatal(err)
}
if err := s.Rename(1, "旧名字", "新名字"); err != nil {
t.Fatalf("Rename: %v", err)
}
var count int64
if err := gdb.Model(&db.Message{}).
Where("user_id = 1 AND folder = ?", "新名字").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("messages in 新名字 = %d, want 1", count)
}
if _, err := s.GetByName(1, "旧名字"); err == nil {
t.Fatal("old mailbox name should be gone")
}
}
// TestMailboxSubscribed 验证订阅状态写入。
func TestMailboxSubscribed(t *testing.T) {
s, _ := newMailboxTestStore(t)
if err := s.EnsureSystem(1); err != nil {
t.Fatal(err)
}
if err := s.SetSubscribed(1, "Trash", false); err != nil {
t.Fatalf("SetSubscribed: %v", err)
}
mb, err := s.GetByName(1, "Trash")
if err != nil {
t.Fatal(err)
}
if mb.IsSubscribed {
t.Fatal("Trash should be unsubscribed")
}
}
+3
View File
@@ -16,6 +16,7 @@ type Stores struct {
Outbound OutboundStore
ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore
Mailboxes MailboxStore
}
// NewStores creates a new Stores instance with all GORM-backed implementations.
@@ -29,6 +30,7 @@ func NewStores(database *gorm.DB) *Stores {
Outbound: newOutboundStore(database),
ProtocolLogs: newProtocolLogStore(database),
MailboxState: newMailboxStateStore(database),
Mailboxes: newMailboxStore(database),
}
}
@@ -40,3 +42,4 @@ var _ = db.Attachment{}
var _ = db.BanEntry{}
var _ = db.ProtocolLog{}
var _ = db.MailboxState{}
var _ = db.Mailbox{}
+236 -276
View File
@@ -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.
+188
View File
@@ -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)
}
@@ -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 },
}
}
+15 -7
View File
@@ -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?= <zhangsan@lmve.net>", 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",
+33 -8
View File
@@ -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)
+5 -5
View File
@@ -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
+20 -15
View File
@@ -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 @@
写信
</a>
<nav class="folder-nav">
<a class="folder {{if eq .activeFolder `inbox`}}active{{end}}" href="/inbox">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
收件箱
{{if .inboxUnread}}<span class="badge">{{.inboxUnread}}</span>{{end}}
</a>
<a class="folder {{if eq .activeFolder `drafts`}}active{{end}}" href="/drafts">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
草稿箱
{{if .draftsTotal}}<span class="count">{{.draftsTotal}}</span>{{end}}
</a>
<a class="folder {{if eq .activeFolder `sent`}}active{{end}}" href="/sent">
{{range .folders}}
<a class="folder {{if eq $.activeFolder .Name}}active{{end}}" href="/folder/{{urlPath .Name}}">
{{if eq .SpecialUse "Sent"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
已发送
{{if .sentTotal}}<span class="count">{{.sentTotal}}</span>{{end}}
{{else if eq .SpecialUse "Drafts"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
{{else if eq .SpecialUse "Trash"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{{else if eq .Name "INBOX"}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{{else}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{{end}}
{{folderLabel .Name}}
{{if eq .Name "INBOX"}}
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
</a>
{{end}}
</nav>
<div class="sidebar-footer">
{{if .currentUser.IsAdmin}}
@@ -1,10 +1,10 @@
{{define "drafts"}}
{{define "folder"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>草稿箱 - MailGo</title>
<title>{{folderLabel .folder}} - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
@@ -23,29 +23,46 @@
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
{{if .isTrash}}彻底删除{{else}}删除{{end}}
</button>
{{if .isTrash}}
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');">
<button type="submit" class="tb-btn danger">清空文件夹</button>
</form>
{{end}}
<div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">📝</span>草稿箱暂无邮件</div>
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div>
</div>
{{else}}
<ul class="mail-list">
{{range .messages}}
<li class="mail-row" data-id="{{.ID}}">
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}">
</label>
<span class="cell-avatar">
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
{{else}}
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
{{end}}
</span>
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}}
{{else}}
{{mailName (decodeHeader .FromAddr)}}
{{end}}
</span>
<span class="cell-from" title="收件人:{{.ToAddr}}">致:{{mailName .ToAddr}}</span>
<span class="cell-subject-wrap">
<a class="cell-subject" href="/drafts/{{.ID}}">
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</span>
@@ -53,12 +70,21 @@
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
{{if $.isTrash}}
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');">
<button type="submit" class="icon-btn" title="恢复到收件箱">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="return confirm('确定要删除这封草稿吗?');">
onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="icon-btn" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
{{end}}
</li>
{{end}}
</ul>
@@ -68,7 +94,7 @@
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/drafts?page={{sub .page 1}}">
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
</a>
@@ -76,7 +102,7 @@
<span class="page-btn disabled">上一页</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/drafts?page={{add .page 1}}">
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
-88
View File
@@ -1,88 +0,0 @@
{{define "inbox"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>收件箱 - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
{{template "navbar" .}}
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main">
<div class="list-toolbar">
<label class="check-all" title="全选/取消全选">
<input type="checkbox" id="select-all">
全选
</label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">📭</span>收件箱暂无邮件</div>
</div>
{{else}}
<ul class="mail-list">
{{range .messages}}
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}">
</label>
<span class="cell-avatar">
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
</span>
<span class="cell-from" title="{{decodeHeader .FromAddr}}">{{mailName (decodeHeader .FromAddr)}}</span>
<span class="cell-subject-wrap">
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/inbox/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</span>
<span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
</li>
{{end}}
</ul>
{{end}}
<div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/inbox?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
</a>
{{else}}
<span class="page-btn disabled">上一页</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/inbox?page={{add .page 1}}">
下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}
<span class="page-btn disabled">下一页</span>
{{end}}
</div>
</div>
</main>
</div>
{{template "listjs" .}}
</body>
</html>
{{end}}
-93
View File
@@ -1,93 +0,0 @@
{{define "sent"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>已发送 - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
{{template "navbar" .}}
<div class="app-body">
{{template "sidebar" .}}
<main class="mail-main">
<div class="list-toolbar">
<label class="check-all" title="全选/取消全选">
<input type="checkbox" id="select-all">
全选
</label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<div class="toolbar-spacer"></div>
<span class="page-info">共 {{.total}} 封</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">📤</span>已发送暂无邮件</div>
</div>
{{else}}
<ul class="mail-list">
{{range .messages}}
<li class="mail-row" data-id="{{.ID}}">
<label class="cell-check" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-id="{{.ID}}">
</label>
<span class="cell-avatar">
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
</span>
<span class="cell-from" title="收件人:{{.ToAddr}}">{{mailName .ToAddr}}</span>
<span class="cell-subject-wrap">
<a class="cell-subject" href="/sent/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
</a>
</span>
<span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="icon-btn" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
</li>
{{end}}
</ul>
{{end}}
<div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/sent?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
</a>
{{else}}
<span class="page-btn disabled">上一页</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/sent?page={{add .page 1}}">
下一页
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}
<span class="page-btn disabled">下一页</span>
{{end}}
</div>
</div>
</main>
</div>
{{template "listjs" .}}
</body>
</html>
{{end}}
+32
View File
@@ -21,6 +21,21 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复
</a>
{{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复
</button>
</form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
<button type="submit" class="tb-btn danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="tb-btn danger">
@@ -28,6 +43,7 @@
删除
</button>
</form>
{{end}}
</div>
<div class="mail-head">
@@ -74,6 +90,21 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复
</a>
{{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复到收件箱
</button>
</form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
<button type="submit" class="btn btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="btn btn-danger">
@@ -81,6 +112,7 @@
删除邮件
</button>
</form>
{{end}}
</div>
</main>
</div>