diff --git a/internal/imap_server/integration_test.go b/internal/imap_server/integration_test.go index aa607f8..7fe0e0d 100644 --- a/internal/imap_server/integration_test.go +++ b/internal/imap_server/integration_test.go @@ -43,7 +43,7 @@ func startIntegrationServer(t *testing.T) (*store.Stores, string) { t.Fatalf("create user: %v", err) } - srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New()) + srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New(), nil) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) diff --git a/internal/imap_server/notify_test.go b/internal/imap_server/notify_test.go index e26758f..1f63e45 100644 --- a/internal/imap_server/notify_test.go +++ b/internal/imap_server/notify_test.go @@ -41,7 +41,7 @@ func newTestServer(t *testing.T) (*IMAPServer, *store.Stores) { t.Fatalf("create user: %v", err) } - srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New()) + srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New(), nil) return srv, stores } @@ -91,7 +91,7 @@ func TestPushNewMessageNoSession(t *testing.T) { func TestPushNewMessageNilSafe(t *testing.T) { var srv *IMAPServer srv.PushNewMessage("a@b", &db.Message{ID: 1}) // 不应 panic - srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil) + srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil, nil) srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱 srv.PushNewMessage("a@b", nil) // 空消息 srv.PushFlagsChanged("", "", nil) diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index 0ae5307..ae40c4e 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -11,6 +11,7 @@ import ( "mail_go/config" "mail_go/internal/connhub" "mail_go/internal/db" + "mail_go/internal/storage" "mail_go/internal/store" "mail_go/internal/tlsutil" @@ -36,6 +37,7 @@ type IMAPServer struct { banCfg config.BanConfig tlsLoader *tlsutil.Loader hub *connhub.Hub + storage *storage.AttachmentStorage // 附件清理(EXPUNGE 时删文件 + 退配额),可空 // svc 邮箱服务层(文件夹目录/消息操作),IMAP 会话与 Web 共用。 svc *MailboxService @@ -49,13 +51,14 @@ type IMAPServer struct { // NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil // when TLS is not configured. -func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *IMAPServer { +func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, attStorage *storage.AttachmentStorage) *IMAPServer { return &IMAPServer{ stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, + storage: attStorage, svc: NewMailboxService(stores), hubs: make(map[string]*mailboxHub), sessions: make(map[*imapSession]struct{}), diff --git a/internal/imap_server/session.go b/internal/imap_server/session.go index a72faf4..58cd548 100644 --- a/internal/imap_server/session.go +++ b/internal/imap_server/session.go @@ -969,6 +969,7 @@ func (s *imapSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) er seq uint32 } var targets []target + toPurge := make([]db.Message, 0, len(msgs)) for i := range msgs { msg := &msgs[i] if !msg.IsDeleted { @@ -978,17 +979,15 @@ func (s *imapSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) er continue } targets = append(targets, target{id: msg.ID, seq: uint32(i + 1)}) + toPurge = append(toPurge, *msg) } if len(targets) == 0 { return nil } - ids := make([]uint, len(targets)) - for i, t := range targets { - ids[i] = t.id - } - if err := s.srv.stores.Mails.DeleteMany(ids); err != nil { - log.Printf("IMAP: failed to expunge %d messages: %v", len(ids), err) + // 永久删除:附件文件清理 + 配额回退 + 记录删除(与 Web/POP3 删除同源) + if err := s.srv.stores.PurgeMessages(s.srv.storage, userID, toPurge); err != nil { + log.Printf("IMAP: failed to expunge %d messages: %v", len(toPurge), err) return err } diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index ff72482..a1d0ada 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -15,6 +15,7 @@ import ( "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/imap_server" + "mail_go/internal/storage" "mail_go/internal/store" "mail_go/internal/tlsutil" ) @@ -23,6 +24,7 @@ import ( type POP3Server struct { listener net.Listener stores *store.Stores + storage *storage.AttachmentStorage // 附件清理(QUIT 删除时删文件 + 退配额),可空 cfg config.POP3Config banCfg config.BanConfig tlsLoader *tlsutil.Loader @@ -32,9 +34,10 @@ type POP3Server struct { } // NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil -// when TLS is not configured. -func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *POP3Server { - return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher} +// when TLS is not configured; attStorage may be nil (deletion still refunds +// quota and removes records, only file cleanup is skipped). +func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher, attStorage *storage.AttachmentStorage) *POP3Server { + return &POP3Server{stores: stores, storage: attStorage, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher} } func (s *POP3Server) tlsConfig() (*tls.Config, error) { @@ -556,8 +559,10 @@ func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool var seqs []uint32 for seqNum, msgDeleted := range deleted { if msgDeleted && seqNum >= 1 && seqNum <= len(messages) { - if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil { - log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err) + m := messages[seqNum-1] + // 永久删除:附件文件清理 + 配额回退 + 记录删除(与 Web/IMAP 同源) + if err := s.stores.PurgeMessages(s.storage, user.ID, []db.Message{*m.message}); err != nil { + log.Printf("POP3: failed to delete message %d: %v", m.id, err) continue } count++ diff --git a/internal/pop3_server/server_test.go b/internal/pop3_server/server_test.go index 86647e4..7a6f3dc 100644 --- a/internal/pop3_server/server_test.go +++ b/internal/pop3_server/server_test.go @@ -3,12 +3,15 @@ package pop3_server import ( "bufio" "net" + "os" + "path/filepath" "strings" "testing" "time" "mail_go/config" "mail_go/internal/db" + "mail_go/internal/storage" "mail_go/internal/store" "golang.org/x/crypto/bcrypt" @@ -247,3 +250,59 @@ func TestExpungePushesIMAPUpdate(t *testing.T) { t.Fatalf("inbox count = %d, want 2", n) } } + +// TestExpungeDeletedRemovesAttachmentsAndRefundsQuota 验证 POP3 删除邮件时 +// 附件文件被清理、配额被回退(与 Web/IMAP 删除路径同源)。 +func TestExpungeDeletedRemovesAttachmentsAndRefundsQuota(t *testing.T) { + s := newTestServer(t) + attDir := t.TempDir() + attStorage := storage.NewAttachmentStorage(attDir) + s.storage = attStorage + + domain := &db.Domain{Name: "example.com"} + if err := s.stores.Domains.Create(domain); err != nil { + t.Fatalf("create domain: %v", err) + } + user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true, QuotaBytes: 1 << 20, UsedBytes: 11} + if err := s.stores.Users.Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "with attachment", Date: time.Now()} + if err := s.stores.Mails.Create(msg); err != nil { + t.Fatalf("create message: %v", err) + } + relPath, err := attStorage.Save("a.txt", []byte("hello world")) + if err != nil { + t.Fatalf("save attachment: %v", err) + } + att := &db.Attachment{MessageID: msg.ID, FileName: "a.txt", FilePath: relPath, FileSize: 11} + if err := s.stores.Attachments.Create(att); err != nil { + t.Fatalf("create attachment: %v", err) + } + + msgs := []pop3Message{{id: msg.ID, raw: "x", size: 1, message: msg}} + if n := s.expungeDeleted(msgs, map[int]bool{1: true}, user); n != 1 { + t.Fatalf("expungeDeleted = %d, want 1", n) + } + + // 附件文件已删除 + if _, err := os.Stat(filepath.Join(attDir, relPath)); !os.IsNotExist(err) { + t.Fatalf("attachment file should be gone (err=%v)", err) + } + // 附件记录已删除 + if _, err := s.stores.Attachments.GetByID(att.ID); err == nil { + t.Fatal("attachment record should be gone") + } + // 邮件已删除 + if _, err := s.stores.Mails.GetByID(msg.ID); err == nil { + t.Fatal("message should be gone") + } + // 配额已回退 + u, err := s.stores.Users.GetByID(user.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if u.UsedBytes != 0 { + t.Fatalf("used_bytes = %d, want 0 after refund", u.UsedBytes) + } +} diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index fc5eef5..2988455 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -342,19 +342,36 @@ func (s *smtpSession) Data(r io.Reader) error { return err } + // 附件配额:入站路径此前只记账不检查,外部匿名发件人可无限落盘直至 + // 磁盘写满。口径与 Web 上传一致(只计附件)。任一本地收件人配额不足 + // 则整封以 452 临时失败拒收(发件方按退避重试),避免部分投递; + // 附件总量在 DATA 阶段才可知,无法在 RCPT 阶段按收件人拒绝。 + attTotal := attachmentSize(parsed) + rcptUser, charged, qerr := s.reserveLocalQuota(attTotal) + if qerr != nil { + return qerr + } + // Local recipients: deliver to INBOX. localDelivered := 0 + storedByUser := make(map[uint]int64, len(charged)) for _, rcpt := range s.localRcpts { + if _, reserved := rcptUser[rcpt]; attTotal > 0 && !reserved { + // 预扣阶段未覆盖的收件人(当时不存在):跳过,避免绕过配额 + log.Printf("SMTP: recipient %s missing at quota reservation, skipping", rcpt) + continue + } user, err := s.localUserByEmail(rcpt) if err != nil { log.Printf("SMTP: recipient not found %s, skipping", rcpt) continue } - msg, err := s.saveMessage(user.ID, "INBOX", parsed, data, false) + msg, stored, err := s.saveMessage(user.ID, "INBOX", parsed, data, false) if err != nil { log.Printf("SMTP: failed to create message for %s: %v", rcpt, err) continue } + storedByUser[user.ID] += stored log.Printf("SMTP: message delivered to %s", rcpt) localDelivered++ // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) @@ -362,6 +379,9 @@ func (s *smtpSession) Data(r io.Reader) error { pusher.PushNewMessage(user.Username+"@"+user.Domain.Name, msg) } } + // 配额对账:退还预扣量中未真正落库的部分(收件人被删、落库失败、 + // 部分附件保存失败),保证配额与实际占用一致。 + s.reconcileQuota(charged, storedByUser) s.msgCount += localDelivered // External recipients: queue for outbound delivery. @@ -389,7 +409,20 @@ func (s *smtpSession) Data(r io.Reader) error { s.msgCount += externalQueued if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound { - if _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil { + // Sent 副本尽力而为:本地/外发投递已完成,不因配额回滚整封; + // 附件量先原子预扣,保存后按实际落库量对账退还。 + if attTotal > 0 { + if ok, err := s.backend.server.stores.Users.TryReserveQuota(s.userID, attTotal); err != nil { + log.Printf("SMTP: quota check for sent copy of %s failed: %v", s.email, err) + } else if !ok { + log.Printf("SMTP: sender %s over quota, skipping sent copy", s.email) + } else if _, stored, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil { + _ = s.backend.server.stores.Users.UpdateUsedBytes(s.userID, -attTotal) + log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err) + } else if refund := attTotal - stored; refund > 0 { + _ = s.backend.server.stores.Users.UpdateUsedBytes(s.userID, -refund) + } + } else if _, _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil { log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err) } } @@ -490,7 +523,85 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) { return msg, nil } -func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, error) { +// attachmentSize 汇总邮件附件总字节数(配额记账口径与 Web 上传一致)。 +func attachmentSize(parsed *parsedSMTPMessage) int64 { + total := int64(0) + for _, att := range parsed.attachments { + total += int64(len(att.data)) + } + return total +} + +// reserveLocalQuota 为本地收件人预扣附件配额(TryReserveQuota 原子预扣, +// 防止并发投递绕过配额)。任一收件人配额不足则回退全部已预扣量,并返回 +// 452 临时失败(普通 error 会被 go-smtp 映射为 554 永久失败,语义错误)。 +// 同一收件人重复 RCPT TO 时投递多份,预扣量按用户聚合。 +// 返回 rcpt→userID 映射与各用户预扣量,供投递与对账使用。 +func (s *smtpSession) reserveLocalQuota(attTotal int64) (map[string]uint, map[uint]int64, *smtp.SMTPError) { + rcptUser := make(map[string]uint, len(s.localRcpts)) + if attTotal <= 0 { + return rcptUser, nil, nil + } + quotaNeed := make(map[uint]int64, len(s.localRcpts)) + for _, rcpt := range s.localRcpts { + if _, ok := rcptUser[rcpt]; ok { + continue + } + user, err := s.localUserByEmail(rcpt) + if err != nil { + continue // 投递阶段同样会跳过 + } + rcptUser[rcpt] = user.ID + quotaNeed[user.ID] += attTotal + } + charged := make(map[uint]int64, len(quotaNeed)) + for uid, amount := range quotaNeed { + ok, err := s.backend.server.stores.Users.TryReserveQuota(uid, amount) + if err != nil { + s.refundQuota(charged) + s.recordFail("配额检查失败") + return nil, nil, &smtp.SMTPError{ + Code: 452, + EnhancedCode: smtp.EnhancedCode{4, 3, 0}, + Message: fmt.Sprintf("Temporary quota check failure: %v", err), + } + } + if !ok { + s.refundQuota(charged) + log.Printf("SMTP: recipient over quota (needs %d bytes), rejecting message", amount) + s.recordFail("收件人邮箱配额不足") + return nil, nil, &smtp.SMTPError{ + Code: 452, + EnhancedCode: smtp.EnhancedCode{4, 2, 2}, + Message: "Insufficient system storage", + } + } + charged[uid] = amount + } + return rcptUser, charged, nil +} + +// refundQuota 回退预扣阶段已扣减的配额(整封拒收时保持原子性)。 +func (s *smtpSession) refundQuota(charged map[uint]int64) { + for uid, amount := range charged { + _ = s.backend.server.stores.Users.UpdateUsedBytes(uid, -amount) + } +} + +// reconcileQuota 对账:退还预扣量中未真正落库的部分(收件人被删、落库 +// 失败、部分附件保存失败),使配额与实际占用一致。 +func (s *smtpSession) reconcileQuota(charged, storedByUser map[uint]int64) { + for uid, amount := range charged { + if refund := amount - storedByUser[uid]; refund > 0 { + _ = s.backend.server.stores.Users.UpdateUsedBytes(uid, -refund) + } + } +} + +// saveMessage 存储一封邮件及其附件。配额由调用方预扣并对账:本函数返回 +// 实际落库的附件字节数(stored),调用方据此退还未落库的预扣量; +// Mails.Create 失败时附件尚未处理,调用方应退还全部预扣量。 +func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, int64, error) { msg := &db.Message{ UserID: userID, MessageID: parsed.messageID, @@ -507,11 +618,13 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP Date: parsed.date, } if err := s.backend.server.stores.Mails.Create(msg); err != nil { - return nil, err + return nil, 0, err } // Persist attachments to disk and link them to the message so that the - // Web mail UI can list/download them and quota accounting stays correct. + // Web mail UI can list/download them. Quota is reserved by the caller + // (TryReserveQuota) and reconciled against the returned stored size. + var stored int64 for _, att := range parsed.attachments { relPath, err := s.backend.server.storage.Save(att.fileName, att.data) if err != nil { @@ -529,9 +642,9 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP log.Printf("SMTP: failed to create attachment record: %v", err) continue } - _ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize) + stored += int64(len(att.data)) } - return msg, nil + return msg, stored, nil } // Reset clears the session state for the next message on the same connection. diff --git a/internal/smtp_server/server_test.go b/internal/smtp_server/server_test.go index 8e4e5db..b65ed8f 100644 --- a/internal/smtp_server/server_test.go +++ b/internal/smtp_server/server_test.go @@ -2,6 +2,7 @@ package smtp_server import ( "bytes" + "errors" "fmt" "testing" "time" @@ -12,6 +13,7 @@ import ( "mail_go/internal/store" "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -86,7 +88,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) { t.Fatalf("create user: %v", err) } - if _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil { + if _, _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil { t.Fatalf("saveMessage: %v", err) } @@ -212,3 +214,135 @@ func TestSessionLoggingRecordsDelivery(t *testing.T) { t.Fatalf("expected success, got %+v", logs[0]) } } + +// newQuotaTestSession 构造带真实存储的入站会话(配额逻辑不触碰连接, +// 直接构造会话即可单测)。 +func newQuotaTestSession(t *testing.T, quotaBytes, usedBytes int64) (*smtpSession, *store.Stores, *storage.AttachmentStorage, *db.User) { + t.Helper() + gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + 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{}, &db.ProtocolLog{}); err != nil { + t.Fatalf("migrate: %v", err) + } + stores := store.NewStores(gdb) + attStorage := storage.NewAttachmentStorage(t.TempDir()) + srv := &SMTPServer{stores: stores, storage: attStorage} + sess := &smtpSession{ + backend: &smtpBackend{server: srv, mode: smtpModeInbound}, + clientIP: "203.0.113.10", + startedAt: time.Now(), + port: 25, + rcpts: make([]string, 0), + } + + domain := &db.Domain{Name: "example.com"} + if err := stores.Domains.Create(domain); err != nil { + t.Fatalf("create domain: %v", err) + } + user := &db.User{Username: "alice", PasswordHash: "x", DomainID: domain.ID, IsActive: true, QuotaBytes: quotaBytes, UsedBytes: usedBytes} + if err := stores.Users.Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + if err := sess.Rcpt("alice@example.com", nil); err != nil { + t.Fatalf("Rcpt: %v", err) + } + return sess, stores, attStorage, user +} + +// TestInboundQuotaRejectsOverQuotaRecipient 验证收件人配额不足时整封以 +// 452 临时失败拒收,不落库、不改配额(已预扣份额全部回退)。 +func TestInboundQuotaRejectsOverQuotaRecipient(t *testing.T) { + sess, stores, _, user := newQuotaTestSession(t, 5, 5) + + parsed, err := parseSMTPMessage(testMultipartMessage()) + if err != nil { + t.Fatalf("parseSMTPMessage: %v", err) + } + _, _, qerr := sess.reserveLocalQuota(attachmentSize(parsed)) + if qerr == nil { + t.Fatal("expected 452 for over-quota recipient") + } + var smtpErr *smtp.SMTPError + if !errors.As(qerr, &smtpErr) || smtpErr.Code != 452 { + t.Fatalf("expected 452 SMTPError, got %v", qerr) + } + if count, _ := stores.Mails.CountAll(); count != 0 { + t.Fatalf("expected 0 messages after rejection, got %d", count) + } + u, err := stores.Users.GetByID(user.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if u.UsedBytes != 5 { + t.Fatalf("used_bytes = %d, want 5 (unchanged)", u.UsedBytes) + } +} + +// TestInboundQuotaDeliversWithinQuota 验证配额充足时正常投递:预扣量与 +// 实际落库量一致,对账不产生退还,附件文件与记录就位。 +func TestInboundQuotaDeliversWithinQuota(t *testing.T) { + sess, stores, attStorage, user := newQuotaTestSession(t, 1<<20, 0) + + parsed, err := parseSMTPMessage(testMultipartMessage()) + if err != nil { + t.Fatalf("parseSMTPMessage: %v", err) + } + attTotal := attachmentSize(parsed) // "hello world" = 11 字节 + rcptUser, charged, qerr := sess.reserveLocalQuota(attTotal) + if qerr != nil { + t.Fatalf("reserveLocalQuota: %v", qerr) + } + if rcptUser["alice@example.com"] != user.ID || charged[user.ID] != attTotal { + t.Fatalf("unexpected reservation: rcptUser=%v charged=%v", rcptUser, charged) + } + + msg, stored, err := sess.saveMessage(user.ID, "INBOX", parsed, testMultipartMessage(), false) + if err != nil { + t.Fatalf("saveMessage: %v", err) + } + if stored != attTotal { + t.Fatalf("stored = %d, want %d", stored, attTotal) + } + sess.reconcileQuota(charged, map[uint]int64{user.ID: stored}) + + atts, err := stores.Attachments.ListByMessage(msg.ID) + if err != nil || len(atts) != 1 { + t.Fatalf("expected 1 attachment, got %d (err=%v)", len(atts), err) + } + if content, err := attStorage.Read(atts[0].FilePath); err != nil || !bytes.Equal(content, []byte("hello world")) { + t.Fatalf("attachment file missing or wrong content (err=%v)", err) + } + u, err := stores.Users.GetByID(user.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if u.UsedBytes != attTotal { + t.Fatalf("used_bytes = %d, want %d", u.UsedBytes, attTotal) + } +} + +// TestInboundQuotaReconcilesRefund 验证附件未落库(投递失败等)时,对账 +// 把预扣量完全回退,配额不泄漏。 +func TestInboundQuotaReconcilesRefund(t *testing.T) { + sess, stores, _, user := newQuotaTestSession(t, 1<<20, 0) + + parsed, err := parseSMTPMessage(testMultipartMessage()) + if err != nil { + t.Fatalf("parseSMTPMessage: %v", err) + } + _, charged, qerr := sess.reserveLocalQuota(attachmentSize(parsed)) + if qerr != nil { + t.Fatalf("reserveLocalQuota: %v", qerr) + } + // 模拟投递失败:stored 为空 → 对账全额回退 + sess.reconcileQuota(charged, map[uint]int64{}) + u, err := stores.Users.GetByID(user.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if u.UsedBytes != 0 { + t.Fatalf("used_bytes = %d, want 0 after refund", u.UsedBytes) + } +} diff --git a/internal/store/purge.go b/internal/store/purge.go new file mode 100644 index 0000000..f87b6fb --- /dev/null +++ b/internal/store/purge.go @@ -0,0 +1,36 @@ +package store + +import ( + "log" + + "mail_go/internal/db" + "mail_go/internal/storage" +) + +// PurgeMessages 永久删除一批邮件:逐封删除附件文件并回退其占用的配额、 +// 删除附件记录,最后批量删除邮件记录。Web 删除/清空、IMAP EXPUNGE、 +// POP3 删除共用本入口,保证磁盘文件、配额与数据库三者一致。 +// att 为 nil 时跳过文件清理(仍回退配额并删除记录,测试与降级场景使用)。 +func (s *Stores) PurgeMessages(att *storage.AttachmentStorage, userID uint, msgs []db.Message) error { + ids := make([]uint, 0, len(msgs)) + for i := range msgs { + attachments, err := s.Attachments.ListByMessage(msgs[i].ID) + if err != nil { + log.Printf("store: 查询附件失败 msg=%d: %v", msgs[i].ID, err) + } + for _, a := range attachments { + if att != nil { + _ = att.Delete(a.FilePath) + } + _ = s.Users.UpdateUsedBytes(userID, -a.FileSize) + } + if err := s.Attachments.DeleteByMessage(msgs[i].ID); err != nil { + log.Printf("store: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err) + } + ids = append(ids, msgs[i].ID) + } + if len(ids) == 0 { + return nil + } + return s.Mails.DeleteMany(ids) +} diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index abb9eb4..9498441 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -99,21 +99,10 @@ func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32 return 0 } -// purgeMessages 永久删除邮件(含附件文件与配额回退)。 +// purgeMessages 永久删除邮件(含附件文件与配额回退),委托给与 IMAP/POP3 +// 删除路径同源的 store.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 { + if err := h.stores.PurgeMessages(h.storage, userID, msgs); err != nil { log.Printf("web: 删除邮件失败: %v", err) } } diff --git a/main.go b/main.go index f149f38..5786b61 100644 --- a/main.go +++ b/main.go @@ -242,7 +242,7 @@ func main() { connHub := connhub.New() // 7. Start IMAP server(先于 SMTP 创建,SMTP 投递成功时通知其推送) - imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub) + imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub, attStorage) go func() { if err := imapSrv.Start(); err != nil { log.Printf("IMAP 服务启动失败: %v", err) @@ -279,7 +279,7 @@ func main() { } // 9. Start POP3 server - pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub, imapSrv) + pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub, imapSrv, attStorage) go func() { if err := pop3Srv.Start(); err != nil { log.Printf("POP3 服务启动失败: %v", err) diff --git a/security_todo.md b/security_todo.md index dbfc7cb..235ebd1 100644 --- a/security_todo.md +++ b/security_todo.md @@ -1,8 +1,10 @@ # 安全漏洞修复 TODO -依据 2026-08-19 的安全审计结果(代码静态审计 + mail.lmve.net 线上验证)整理。 +依据 2026-08-19 首轮安全审计(代码静态审计 + mail.lmve.net 线上验证)、2026-08-20 第二轮审计、2026-08-28 第三轮审计(代码静态审计)整理。 -按优先级排列:P0 立即修复,P1 尽快修复,P2 排期修复,P3 加固项。 +按优先级排列:P0 立即修复,P1 尽快修复,P2 排期修复,P3 加固项。条目编号为发现顺序(跨轮次唯一,非严重度排序);第三轮发现(#19-#26)已按等级归并至下列 P1-P3 区块,标题注明轮次与日期。 + +> 第三轮(2026-08-28)结论:未发现 SQL 注入、路径穿越、开放中继、越权访问、XSS 等高危漏洞,前两轮修复(#1-#18)均验证到位;新发现问题集中在**资源滥用/配额记账缺陷**与**防御深度缺失**两类。执行顺序注意:**#20 必须先于(或随)#19 落地**——配额退还先于配额强制,否则用户会因幽灵占用被误拒收。 ## P0 严重:可被完全接管 @@ -59,6 +61,31 @@ - [x] 单测:`to`/`cc`/`subject` 携带 CRLF 注入载荷时 RawData 无独立注入头;文件名含 CRLF/引号时头结构完好;非 ASCII 主题正确编码(`mail_injection_test.go`)。 - [ ] 含特殊字符附件名的邮件实测收发正常。 +### 19. SMTP 入站投递不强制配额 —— 磁盘耗尽 DoS(第三轮,2026-08-28) + +- [x] 位置:`internal/smtp_server/server.go:493`(`saveMessage`,记账在 532 行) +- 现状:入站邮件附件直接落盘,`UpdateUsedBytes` 只记账不检查配额。对比 Web 上传路径有 `TryReserveQuota` 原子预扣(`user_store.go:191`),入站路径配额形同虚设。任何外部发件人可投递 64MB(`MaxMessage`)× 无限封邮件(附件落盘 + `RawData` 整封入库双份存储)直到磁盘写满,导致全部用户服务中断;多用户租户场景配额承诺无法兑现。 +- 修复方案: + - [x] `saveMessage` 前调用 `TryReserveQuota` 预扣附件总大小(无附件时跳过)。→ 实现 `reserveLocalQuota`:按用户聚合预扣(重复 RCPT TO 投递多份),任一收件人不足则回退全部已预扣量,整封原子拒收 + - [x] 超限返回 `smtp.SMTPError{Code: 452, ...}`(insufficient storage,临时失败语义,发件方按退避重试)。**注意**:go-smtp 对 `Data()` 返回的普通 error 一律映射为 554 永久失败(`conn.go` `dataErrorToStatus`),语义错误会导致对方服务器直接退信。 + - [x] 入库/落盘失败时补偿回退预扣字节。→ `saveMessage` 返回实际落库字节数,`reconcileQuota` 对账退还差额(覆盖收件人被删/落库失败/部分附件失败);Sent 副本为尽力而为:预扣失败仅跳过副本并记日志,不回滚整封 +- 验证: + - [x] 单测:配额已满时入站投递被拒(452)、`used_bytes` 不变;配额充足时正常投递记账。(`TestInboundQuotaRejectsOverQuotaRecipient` / `TestInboundQuotaDeliversWithinQuota` / `TestInboundQuotaReconcilesRefund`) + - [x] 回归:本地投递 + 外发队列 + Sent 副本路径不受影响。(`go test ./...` 全量通过) +- 已完成(2026-08-28)。 + +### 20. 删除邮件不删附件文件、不退配额 —— 幽灵配额(第三轮新发现,2026-08-28) + +- [x] 位置:`internal/imap_server/session.go:956`(`Expunge`)、`internal/pop3_server/server.go:551`(`expungeDeleted`) +- 现状:两处只调 `Mails.Delete`/`DeleteMany`,不删附件文件、不回退 `UsedBytes`——配额只增不减。Web 侧 `purgeMessages`(`web/handlers/mail.go:103`)是完整实现(`storage.Delete` + `DeleteByMessage` + `UpdateUsedBytes` 回退),协议侧缺失。**与 #19 配套必须修复**,否则强制配额后用户会因幽灵占用被拒收且无法自助恢复。 +- 修复方案: + - [x] 抽取共享清理入口(store 层):删附件记录前先 `Attachments.ListByMessage` 取文件路径,`storage.Delete` + `UpdateUsedBytes(userID, -FileSize)` 回退,再删记录与邮件。→ 新增 `store.PurgeMessages`(Web/IMAP/POP3 同源) + - [x] IMAP `Expunge`(含 UID EXPUNGE 分支)与 POP3 `expungeDeleted` 接入。 + - [x] IMAP 服务器需注入 `AttachmentStorage`(`NewIMAPServer` 当前无此依赖);注意 `pop3_server_test.go:29` 直接构造 `POP3Server{stores:...}`、`imap_server` 两个测试文件直接构造服务器的用例需同步。→ `NewIMAPServer`/`NewPOP3Server` 增参,main.go 与三个测试文件已同步 +- 验证: + - [x] 单测:带附件邮件经 IMAP EXPUNGE / POP3 DELE+QUIT 删除后附件文件消失、`used_bytes` 回退。(`TestExpungeDeletedRemovesAttachmentsAndRefundsQuota` 覆盖 POP3 路径与共享入口;IMAP EXPUNGE 为同一入口的薄接线) +- 已完成(2026-08-28)。注:Web 侧 `purgeMessages` 改为委托同一入口,消除三处重复实现。 + ## P2 中危 ### 5. 会话 Cookie 缺 Secure 标志 @@ -136,6 +163,26 @@ - [x] 现有 OAuth2 测试仍通过(错误页文案不含内部细节)。 - [ ] 线上(启用 LDAP/OAuth 后)验证失败页面不含内部地址/DN/原始错误串。 +### 21. Web 发信配额预扣泄漏(第三轮,2026-08-28) + +- [ ] 位置:`internal/web/handlers/mail.go:278`(预扣)→ 367 / 390 / 416(泄漏点) +- 现状:`TryReserveQuota` 预扣附件配额后,三处提前 return 不回退:外部收件人入队失败(367)、本地投递失败(390)、Sent 副本保存失败(416)。文件读取失败(310)与附件保存失败(426)有回退,上述路径漏了。用户反复提交含无效外部地址的带附件邮件,可把自己配额扣光且无对应文件占用(配额"凭空消失",需管理员改库恢复)。 +- 修复方案: + - [ ] defer + 成功标志统一补偿:未走到"附件记录全部落库"终点即回退剩余预扣量;或各失败分支显式回退。 + - [ ] 注意 367 处入队失败时可能已成功入队部分外部收件人,只回退附件未消耗部分。 +- 验证: + - [ ] 单测:外部入队失败 / 本地投递失败路径退出后 `used_bytes` 恢复原值。 + +### 22. OAuth2 登录不校验邮箱 verified 状态(第三轮,2026-08-28) + +- [ ] 位置:`internal/auth/oauth2.go:111-127` +- 现状:GitHub `/user/emails` 响应含 `verified` 字段但未检查,取 primary 首个即用;Google 未检查 `email_verified`。当前 GitHub 该 API 实际只返回已验证邮箱,现实可利用性低,但属依赖 IdP 实现细节;自建 provider(代码 33-36 行支持任意 host)场景下未验证邮箱可登录他人账号。 +- 修复方案: + - [ ] GitHub:过滤 `verified == true`(primary 优先),无已验证邮箱返回错误。 + - [ ] Google:解析 `email_verified`,false 或缺失时拒绝。 +- 验证: + - [ ] 单测:构造含未验证邮箱的响应被拒。 + ## P3 低危 / 加固 ### 12. Referer 开放重定向 @@ -171,6 +218,52 @@ - 验证: - [x] 单测:8 天前的会话被重定向登录页;1 小时前的会话正常访问(用配置密钥签名构造会话,`TestSessionAbsoluteExpiryForcesRelogin`/`TestSessionWithinExpiryWorks`)。 +### 23. 管理员建用户/域名无格式校验(第三轮,2026-08-28) + +- [ ] 位置:`internal/web/handlers/admin.go`(`CreateUser`:612 / `UpdateUser`:737 / `CreateDomain`:222) +- 现状:username 可含 `@`、空格、控制字符;域名无任何校验。用户名 `a@evil` 生成邮箱 `a@evil@example.com`,`GetByEmail` 的 `SplitN("@", 2)`(`user_store.go:75`)解析错位,认证与投递路由混乱;畸形数据也会进入 IMAP 邮箱视图。 +- 修复方案: + - [ ] 用户名白名单 `^[a-zA-Z0-9._-]+$`、长度 ≤64;域名小写字母数字/连字符/至少一个点、长度 ≤253。 + - [ ] `UpdateUser` 改名/换域名同样校验;错误回显走 i18n。 +- 验证: + - [ ] 单测:`a@evil`、含空格/控制字符用户名被拒;畸形域名被拒;合法输入通过。 + +### 24. 密码无最小长度要求(第三轮,2026-08-28) + +- [ ] 位置:`internal/web/handlers/mail.go:849`(自助修改)、`internal/web/handlers/admin.go:638/771`(创建/重置) +- 现状:新密码仅检查非空,1 位密码也接受,与系统整体安全水位不匹配。 +- 修复方案: + - [ ] 统一最小长度 8(NIST SP 800-63B:长度优先于复杂度),三处共用校验函数 + i18n 文案。 +- 验证: + - [ ] 单测:7 位拒绝、8 位通过。 + +### 25. POP3/IMAP 允许明文认证(第三轮,2026-08-28) + +- [ ] 位置:`internal/pop3_server/server.go:390`(`handlePASS`)、`internal/imap_server/session.go:212`(`Login`) +- 现状:非 TLS 连接上 USER/PASS、LOGIN 明文传输无限制,被动嗅探可截获密码。自签证书总是自动生成,STLS/STARTTLS 能力具备(POP3 CAPA 已宣告 STLS)。 +- 修复方案: + - [ ] 回环来源放行(本地调试);非回环要求连接已 TLS(POP3 `tlsActive` / IMAP `NetConn()` 为 `*tls.Conn`)才接受认证,否则提示先执行 STLS/STARTTLS。 + - [ ] 加配置项控制(默认强制),不破坏极端明文内网部署。 +- 验证: + - [ ] 单测:非 TLS 非回环认证被拒并提示;TLS 后成功;回环明文可用。 + +### 26. 加固建议(第三轮,2026-08-28,可选排期) + +- [ ] CSP `script-src 'unsafe-inline'`(`middleware/security.go:19`):长期改 nonce 方案,恢复 XSS 纵深防御。 +- [ ] 邮件远程图片(CSP `img-src https:`):默认屏蔽、点击加载,防追踪像素泄露收件人 IP/UA。 +- [ ] 入站邮件 SPF/DKIM/DMARC 验证:当前伪造发件人的钓鱼邮件原样入库展示。 +- [ ] Unix socket 0666(`web/server.go:493`):本地多用户主机依赖目录权限兜底,可收紧 0660。 +- [ ] RawData 双份存储(附件落盘 + 整封 RawData 入库):存储翻倍,放大 #19 的 DoS 效果。 + +### 27. IncrementFail 并发首建窗口丢失更新(第三轮修复过程中发现,存量问题) + +- [ ] 位置:`internal/store/ban_store.go`(`IncrementFail`) +- 现状:UPDATE 未命中(RowsAffected=0)后 INSERT 用 `OnConflict DoNothing` 兜底——两个 goroutine 的 UPDATE 可都在记录存在前提交,随后 INSERT 竞争,败者被 DoNothing 吞掉、自增丢失,最终 `fail_count` 少 1。生产影响极小(仅同 IP 首次失败瞬时并发时计数少 1,后续自增自愈),但 `TestIncrementFailConcurrent`(16×5 并发,断言精确 80)会因此偶发失败:`-race` 多包并行时观察到一次;干净树验证非第三轮改动引入(改动仅新增 purge.go,未被该测试触及)。 +- 修复方案: + - [ ] 失败 INSERT 改为冲突时自增:`OnConflict{Columns: ip_address, DoUpdates: fail_count = fail_count + 1}`(SQLite ≥3.24 / MySQL 均支持,约 3 行改动),消除首建窗口的丢失更新。 +- 验证: + - [ ] `go test -race -count=N ./internal/store/` 多轮稳定通过。 + ## 已确认安全、无需改动 - bcrypt 密码哈希;GORM 全参数化查询(无 SQL 注入)。 @@ -179,6 +272,14 @@ - 邮件 HTML 经 sandbox iframe(无 `allow-scripts`)渲染,`srcdoc` 属性转义经实测有效,无存储型 XSS。 - Web 登录错误提示不区分用户是否存在(无枚举)。 +第三轮(2026-08-28)复查确认: + +- 存储层 GORM 全参数化(无 SQL 注入);附件路径 UUID 白名单 + 前缀校验(无路径穿越)。 +- SMTP 非开放中继、认证用户强制 From=登录身份;Web/管理端路由权限校验全链路到位。 +- 邮件 HTML 经 sandbox iframe 渲染 + `jsonify` 转义(无存储型 XSS);会话 SameSite=Strict + 7 天绝对过期 + 滑动续期。 +- 邮件头 CRLF 注入过滤;OAuth2 state 常量时间比较 + 一次性;LDAP `EscapeFilter`;Referer 同站回跳。 +- 配置密钥治理(旧硬编码拒绝启动、配置 0600、env 覆盖不落盘);仅信任回环代理。 + ## 修复顺序建议 1. ~~#1(P0)~~ 已完成 2026-08-19 @@ -186,6 +287,7 @@ 3. ~~#5-#11(P2)~~ 已完成 2026-08-19 4. ~~#12-#16(P3)~~ 已完成 2026-08-19 5. ~~#17(P4)、#18(P5,方案 A)~~ 已完成 2026-08-20 +6. 第三轮(#19-#26,2026-08-28)待实施,已按安全等级归并至上文 P1-P3 区块;实施顺序见文末「第三轮修复顺序建议」。 ## P4 低危:第二轮审计发现(2026-08-20,8ea4a62..37b4816) @@ -225,3 +327,13 @@ - Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达。 - GitHub 仓库中 3 个 50MB+ 的 exe 文件(mailgo.exe / mail_go.exe / mailgo_qa.exe)建议改用 Git LFS 或从历史中删除。 - 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)。 + +## 第三轮修复顺序建议(#19-#27,实施顺序 ≠ 安全等级) + +1. ~~**#20**(先修删除退配额,数据一致性基础)~~ 已完成 2026-08-28 +2. ~~**#19**(入站配额强制,依赖 #20)~~ 已完成 2026-08-28 +3. **#21**(Web 预扣泄漏) +4. #24、#23(一行校验类) +5. #22、#25 +6. #26 按需排期 +7. #27(存量丢失更新,约 3 行改动,可顺手修)