diff --git a/README.md b/README.md index 5214f22..50a3a76 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 + - **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名 - **Web 邮箱**:QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载 - **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计 +- **协议调用日志**:SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理) - **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭) - **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封 - **多数据库**:默认 SQLite,可切换 MySQL @@ -87,6 +88,8 @@ secret_key = "" # Web 会话签名密钥;留空时 # 分别意味着会话可被伪造/所有登录态失效) cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志); # 仅本地 HTTP 调试时才改为 false +protocol_log_keep_days = 30 # SMTP/IMAP/POP3 协议调用日志保留天数, + # 超出后由后台任务自动清理;0 表示不清理 [smtp] addr = ":25" # SMTP 明文端口 @@ -383,7 +386,8 @@ mailgo/ │ │ ├── domain_store.go # 域名数据操作 │ │ ├── attachment_store.go # 附件数据操作 │ │ ├── outbound_store.go # 外发队列数据操作 -│ │ └── ban_store.go # 封禁数据操作 +│ │ ├── ban_store.go # 封禁数据操作 +│ │ └── protocol_log_store.go # 协议调用日志数据操作 │ ├── smtp_server/server.go # SMTP 服务 │ ├── outbound/ │ │ ├── mailer.go # MX 查询与 SMTP 出站客户端 diff --git a/config/config.go b/config/config.go index 3052ba5..1e79c38 100644 --- a/config/config.go +++ b/config/config.go @@ -36,6 +36,9 @@ type WebConfig struct { // 默认 true;仅当应用直接以 HTTP 提供服务(本地调试、内网明文)时 // 才应改为 false。 CookieSecure bool `toml:"cookie_secure"` + // ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数, + // 超过该天数的记录会被后台任务自动清理。 + ProtocolLogKeepDays int `toml:"protocol_log_keep_days"` } // SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。 @@ -197,8 +200,9 @@ func defaultConfig() *Config { AttachDir: filepath.Join(bd, "attachments"), }, Web: WebConfig{ - Addr: DefaultWebPort, - CookieSecure: true, + Addr: DefaultWebPort, + CookieSecure: true, + ProtocolLogKeepDays: DefaultProtocolLogKeepDays, }, SMTP: SMTPConfig{ Addr: fmt.Sprintf(":%d", DefaultSMTPPort), @@ -262,6 +266,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config { if cfg.Web.Addr == "" { cfg.Web.Addr = defaults.Web.Addr } + if cfg.Web.ProtocolLogKeepDays == 0 { + cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays + } if cfg.SMTP.Addr == "" { cfg.SMTP.Addr = defaults.SMTP.Addr } diff --git a/config/defaults.go b/config/defaults.go index 13850ec..0e3dbdd 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -36,5 +36,8 @@ const ( DefaultQuotaBytes int64 = 5 * 1024 * 1024 * 1024 // 5GB ) +// DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。 +const DefaultProtocolLogKeepDays = 30 + // ConfigFileName is the name of the configuration file const ConfigFileName = "mail_go.toml" diff --git a/internal/db/db.go b/internal/db/db.go index 5e8ccf9..bd489fb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -46,7 +46,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{}); err != nil { + if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}); err != nil { return nil, fmt.Errorf("数据库迁移失败: %w", err) } diff --git a/internal/db/models.go b/internal/db/models.go index 414164e..56f6cac 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -122,6 +122,34 @@ type BanEntry struct { // TableName specifies the table name for BanEntry. func (BanEntry) TableName() string { return "ban_entries" } +// Protocol log statuses. +const ( + ProtocolSMTP = "smtp" + ProtocolIMAP = "imap" + ProtocolPOP3 = "pop3" +) + +// ProtocolLog records one SMTP/IMAP/POP3 connection session: auth result, +// failure reason and source IP, for admin analysis of attacks/abuse. +type ProtocolLog struct { + ID uint `gorm:"primaryKey" json:"id"` + Protocol string `gorm:"size:16;index;not null" json:"protocol"` // smtp | imap | pop3 + Port int `json:"port"` // 25/465/587/143/993/110/995 + ClientIP string `gorm:"size:64;index;not null" json:"client_ip"` + Username string `gorm:"size:255;index" json:"username"` + Success bool `gorm:"index" json:"success"` + FailReason string `gorm:"size:512" json:"fail_reason"` + Detail string `gorm:"size:2048" json:"detail"` + MsgCount int `json:"msg_count"` + DurationMs int64 `json:"duration_ms"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +// TableName specifies the table name for ProtocolLog. +func (ProtocolLog) TableName() string { + return "protocol_logs" +} + // Attachment represents a file attached to an email message. type Attachment struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index c0ffd02..94f1243 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -28,14 +28,17 @@ import ( type imapBackend struct { stores *store.Stores banCfg config.BanConfig + port int } // Login authenticates a user by email and password. func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) { clientIP := store.ClientIPFromAddr(connInfo.RemoteAddr) + now := time.Now() // 已封禁 IP 一律拒绝认证(防协议层暴力破解) if banned, _ := b.stores.Bans.IsBanned(clientIP); banned { + b.recordLogin(clientIP, username, false, "IP已被封禁", "认证被拒绝(IP 已封禁)", 0, now) return nil, backend.ErrInvalidCredentials } @@ -43,6 +46,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) if err != nil { // 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries) b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin) + b.recordLogin(clientIP, username, false, "用户名或密码错误", "LOGIN 失败", 0, now) return nil, fmt.Errorf("invalid credentials: %w", err) } @@ -52,20 +56,48 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) email = user.Username + "@" + domain.Name } + logID := b.recordLogin(clientIP, username, true, "", "LOGIN 成功", 0, now) + return &imapUser{ - stores: b.stores, - id: user.ID, - email: email, + stores: b.stores, + id: user.ID, + email: email, + logID: logID, + clientIP: clientIP, + startedAt: now, }, nil } +// recordLogin 写入一条 IMAP 登录日志,返回新记录的 ID(失败时为 0)。 +func (b *imapBackend) recordLogin(ip, username string, success bool, failReason, detail string, durationMs int64, at time.Time) uint { + entry := &db.ProtocolLog{ + Protocol: db.ProtocolIMAP, + Port: b.port, + ClientIP: ip, + Username: username, + Success: success, + FailReason: failReason, + Detail: detail, + DurationMs: durationMs, + CreatedAt: at, + } + if err := b.stores.ProtocolLogs.Create(entry); err != nil { + log.Printf("IMAP: 写入协议日志失败: %v", err) + return 0 + } + return entry.ID +} + // ---------- imapUser ---------- // imapUser implements backend.User. type imapUser struct { - stores *store.Stores - id uint - email string + stores *store.Stores + id uint + email string + logID uint + clientIP string + startedAt time.Time } // Username returns the user's email address. @@ -146,6 +178,14 @@ func (u *imapUser) RenameMailbox(existingName, newName string) error { // Logout is called when the user session ends. func (u *imapUser) Logout() error { + // 回填会话时长,登录记录在 Login 时已写入 + if u.logID == 0 { + return nil + } + durationMs := time.Since(u.startedAt).Milliseconds() + if err := u.stores.ProtocolLogs.UpdateDuration(u.logID, durationMs); err != nil { + log.Printf("IMAP: 更新协议日志失败: %v", err) + } return nil } diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index 1f96f2b..20c65b1 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -4,6 +4,8 @@ import ( "crypto/tls" "fmt" "log" + "net" + "strconv" "mail_go/config" "mail_go/internal/store" @@ -42,7 +44,7 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) { // newServer creates a configured imapserver.Server with the given address. func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server { - be := &imapBackend{stores: s.stores, banCfg: s.banCfg} + be := &imapBackend{stores: s.stores, banCfg: s.banCfg, port: portOf(addr)} srv := imapserver.New(be) srv.Addr = addr srv.TLSConfig = tlsConfig @@ -50,6 +52,19 @@ func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.S return srv } +// portOf 从监听地址解析端口号,失败返回 0。 +func portOf(addr string) int { + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + return 0 + } + port, err := strconv.Atoi(portStr) + if err != nil { + return 0 + } + return port +} + // Start starts the IMAP server on the plain-text port. func (s *IMAPServer) Start() error { tlsConfig, err := s.tlsConfig() diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index cbc57ea..16d5123 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -48,6 +48,7 @@ func (s *POP3Server) Start() error { if err != nil { return fmt.Errorf("POP3 listen failed: %w", err) } + port := parseAddrPort(s.cfg.Addr) log.Printf("POP3 server listening on %s", s.cfg.Addr) @@ -62,7 +63,7 @@ func (s *POP3Server) Start() error { s.wg.Add(1) go func() { defer s.wg.Done() - s.handleConn(conn) + s.handleConn(conn, port) }() } }() @@ -81,6 +82,7 @@ func (s *POP3Server) StartTLS() error { if err != nil { return fmt.Errorf("POP3 TLS listen failed: %w", err) } + port := parseAddrPort(s.cfg.TLSAddr) log.Printf("POP3 TLS server listening on %s", s.cfg.TLSAddr) @@ -95,7 +97,7 @@ func (s *POP3Server) StartTLS() error { s.wg.Add(1) go func() { defer s.wg.Done() - s.handleConn(conn) + s.handleConn(conn, port) }() } }() @@ -103,21 +105,44 @@ func (s *POP3Server) StartTLS() error { return nil } +// parseAddrPort 从监听地址解析端口号,失败返回 0。 +func parseAddrPort(addr string) int { + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + return 0 + } + port, err := strconv.Atoi(portStr) + if err != nil { + return 0 + } + return port +} + // handleConn handles a single POP3 client connection. -func (s *POP3Server) handleConn(conn net.Conn) { +func (s *POP3Server) handleConn(conn net.Conn, port int) { defer conn.Close() conn.SetDeadline(time.Now().Add(10 * time.Minute)) clientIP := store.ClientIPFromAddr(conn.RemoteAddr()) + startedAt := time.Now() // 已封禁 IP 直接拒绝(防协议层暴力破解) if banned, _ := s.stores.Bans.IsBanned(clientIP); banned { sendResponse(conn, "-ERR access denied") + s.writeProtocolLog(port, clientIP, "", false, "IP已被封禁", "连接被拒绝", 0, 0, startedAt) return } + // 会话状态(供协议日志汇总) + var ( + authUser *db.User + authUsername string + authFailReason string + commandCount = make(map[string]int) + deletedCount int + ) + reader := bufio.NewReader(conn) - var user *db.User var messages []pop3Message var deleted map[int]bool tlsActive := false @@ -127,7 +152,7 @@ func (s *POP3Server) handleConn(conn net.Conn) { for { line, err := reader.ReadString('\n') if err != nil { - return + break } line = strings.TrimSpace(line) @@ -137,12 +162,13 @@ func (s *POP3Server) handleConn(conn net.Conn) { parts := strings.SplitN(line, " ", 2) cmd := strings.ToUpper(parts[0]) + commandCount[cmd]++ arg := "" if len(parts) > 1 { arg = strings.TrimSpace(parts[1]) } - authenticated := user != nil && user.ID != 0 + authenticated := authUser != nil && authUser.ID != 0 if !authenticated && requiresAuth(cmd) { sendResponse(conn, "-ERR authentication required") continue @@ -150,9 +176,17 @@ func (s *POP3Server) handleConn(conn net.Conn) { switch cmd { case "USER": - user, messages, deleted = s.handleUSER(conn, arg, user) + authUsername = arg + authUser, messages, deleted = s.handleUSER(conn, arg, authUser) case "PASS": - user, messages, deleted = s.handlePASS(conn, arg, user) + authUser, messages, deleted = s.handlePASS(conn, arg, authUser) + if authUser == nil || authUser.ID == 0 { + if authFailReason == "" { + authFailReason = "用户名或密码错误" + } + } else { + authFailReason = "" + } case "STAT": s.handleSTAT(conn, messages, deleted) case "LIST": @@ -167,8 +201,11 @@ func (s *POP3Server) handleConn(conn net.Conn) { deleted = make(map[int]bool) sendResponse(conn, "+OK") case "QUIT": - s.expungeDeleted(messages, deleted, user) + deletedCount = s.expungeDeleted(messages, deleted, authUser) sendResponse(conn, "+OK MailGo POP3 server signing off") + s.writeProtocolLog(port, clientIP, authUsername, authUser != nil && authUser.ID != 0, authFailReason, + pop3CommandDetail(commandCount, deletedCount), deletedCount, + time.Since(startedAt).Milliseconds(), time.Now()) return case "CAPA": s.handleCAPA(conn, tlsActive) @@ -189,6 +226,8 @@ func (s *POP3Server) handleConn(conn net.Conn) { sendResponse(conn, "+OK Begin TLS negotiation") tlsConn := tls.Server(conn, tlsConfig) if err := tlsConn.Handshake(); err != nil { + s.writeProtocolLog(port, clientIP, authUsername, false, "TLS 握手失败", + pop3CommandDetail(commandCount, 0), 0, time.Since(startedAt).Milliseconds(), time.Now()) return } conn = tlsConn @@ -202,6 +241,56 @@ func (s *POP3Server) handleConn(conn net.Conn) { sendResponse(conn, "-ERR unknown command") } } + + // 连接异常结束(未 QUIT) + success := authUser != nil && authUser.ID != 0 && authFailReason == "" + if success && authFailReason == "" && authUsername == "" && len(commandCount) == 0 { + success = true + } + s.writeProtocolLog(port, clientIP, authUsername, success, authFailReason, + pop3CommandDetail(commandCount, 0), 0, time.Since(startedAt).Milliseconds(), time.Now()) +} + +// pop3CommandDetail 汇总会话中执行的命令为可读摘要(计数,忽略 NOOP/CAPA)。 +func pop3CommandDetail(counts map[string]int, deletedCount int) string { + var parts []string + for _, c := range []string{"USER", "PASS", "STAT", "LIST", "RETR", "TOP", "UIDL", "DELE", "RSET", "STLS", "QUIT"} { + n := counts[c] + if n == 0 { + continue + } + if n == 1 { + parts = append(parts, c) + } else { + parts = append(parts, fmt.Sprintf("%s×%d", c, n)) + } + } + if deletedCount > 0 { + parts = append(parts, fmt.Sprintf("删除%d", deletedCount)) + } + if len(parts) == 0 { + return "连接建立,无命令" + } + return strings.Join(parts, " ") +} + +// writeProtocolLog 写入一条 POP3 协议调用日志。 +func (s *POP3Server) writeProtocolLog(port int, ip, username string, success bool, failReason, detail string, msgCount int, durationMs int64, at time.Time) { + entry := &db.ProtocolLog{ + Protocol: db.ProtocolPOP3, + Port: port, + ClientIP: ip, + Username: username, + Success: success, + FailReason: failReason, + Detail: detail, + MsgCount: msgCount, + DurationMs: durationMs, + CreatedAt: at, + } + if err := s.stores.ProtocolLogs.Create(entry); err != nil { + log.Printf("POP3: 写入协议日志失败: %v", err) + } } func requiresAuth(cmd string) bool { @@ -270,6 +359,9 @@ func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db. return &db.User{Username: username}, nil, nil } + // 保留完整的邮箱地址作为登录标识(PASS 阶段用 Authenticate 校验), + // user.ID 用于后续加载邮件。 + user.Username = username sendResponse(conn, "+OK") return user, nil, nil } @@ -425,18 +517,23 @@ func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Messag sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id)) } -// expungeDeleted actually deletes messages that were marked for deletion. -func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) { +// expungeDeleted actually deletes messages that were marked for deletion, +// returning the number of messages deleted. +func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) int { if deleted == nil || user == nil || user.ID == 0 { - return + return 0 } + count := 0 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) + continue } + count++ } } + return count } // sendResponse writes a POP3 response line to the connection. diff --git a/internal/pop3_server/server_test.go b/internal/pop3_server/server_test.go new file mode 100644 index 0000000..6239179 --- /dev/null +++ b/internal/pop3_server/server_test.go @@ -0,0 +1,162 @@ +package pop3_server + +import ( + "bufio" + "net" + "strings" + "testing" + "time" + + "mail_go/config" + "mail_go/internal/db" + "mail_go/internal/store" + + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestServer(t *testing.T) *POP3Server { + 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) + return &POP3Server{ + stores: stores, + cfg: config.POP3Config{}, + banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30}, + } +} + +// TestHandleConnLogsAuthFailure 验证认证失败的 POP3 会话写入协议日志。 +func TestHandleConnLogsAuthFailure(t *testing.T) { + s := newTestServer(t) + + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + s.handleConn(server, 110) + }() + + br := bufio.NewReader(client) + // 等待 greeting + if _, err := br.ReadString('\n'); err != nil { + t.Fatalf("greeting: %v", err) + } + client.Write([]byte("USER no-such-user\r\n")) + br.ReadString('\n') + client.Write([]byte("PASS wrong-pass\r\n")) + br.ReadString('\n') + client.Write([]byte("QUIT\r\n")) + br.ReadString('\n') + client.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("handleConn did not return") + } + + logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 1 { + t.Fatalf("expected 1 log, got %d", total) + } + log := logs[0] + if log.Protocol != db.ProtocolPOP3 || log.Port != 110 { + t.Fatalf("unexpected log: %+v", log) + } + if log.Success { + t.Fatalf("expected failure, got %+v", log) + } + if log.Username != "no-such-user" { + t.Fatalf("username = %q, want no-such-user", log.Username) + } + if !strings.Contains(log.Detail, "USER") || !strings.Contains(log.Detail, "PASS") { + t.Fatalf("detail missing commands: %q", log.Detail) + } +} + +// TestHandleConnLogsSuccess 验证认证成功的 POP3 会话写入成功日志。 +func TestHandleConnLogsSuccess(t *testing.T) { + s := newTestServer(t) + + 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} + hashed, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("hash: %v", err) + } + user.PasswordHash = string(hashed) + if err := s.stores.Users.Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + s.handleConn(server, 110) + }() + + br := bufio.NewReader(client) + br.ReadString('\n') + client.Write([]byte("USER alice@example.com\r\n")) + br.ReadString('\n') + client.Write([]byte("PASS secret123\r\n")) + br.ReadString('\n') + client.Write([]byte("STAT\r\n")) + br.ReadString('\n') + client.Write([]byte("QUIT\r\n")) + br.ReadString('\n') + client.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("handleConn did not return") + } + + logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 1 { + t.Fatalf("expected 1 log, got %d", total) + } + if !logs[0].Success { + t.Fatalf("expected success, got %+v", logs[0]) + } + if logs[0].Username != "alice@example.com" { + t.Fatalf("username = %q", logs[0].Username) + } +} + +// TestPop3CommandDetail 验证命令摘要生成。 +func TestPop3CommandDetail(t *testing.T) { + counts := map[string]int{"USER": 1, "PASS": 1, "RETR": 3, "DELE": 1, "NOOP": 2} + got := pop3CommandDetail(counts, 2) + if got != "USER PASS RETR×3 DELE 删除2" { + t.Fatalf("detail = %q", got) + } + if empty := pop3CommandDetail(nil, 0); empty != "连接建立,无命令" { + t.Fatalf("empty detail = %q", empty) + } +} diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index be8cfbb..8167bad 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "log" + "net" + "strconv" "strings" "time" @@ -113,9 +115,31 @@ func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { mode: be.mode, rcpts: make([]string, 0), clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()), + startedAt: time.Now(), + port: be.server.sessionPort(be.mode), }, nil } +// sessionPort 返回该会话监听的端口号(区分明文/TLS/提交端口),解析失败返回 0。 +func (s *SMTPServer) sessionPort(mode smtpMode) int { + addr := s.cfg.Addr + switch mode { + case smtpModeSubmission: + addr = s.cfg.SubmissionAddr + case smtpModeImplicitTLS: + addr = s.cfg.TLSAddr + } + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + return 0 + } + port, err := strconv.Atoi(portStr) + if err != nil { + return 0 + } + return port +} + // smtpSession implements the smtp.Session interface for handling a single connection. type smtpSession struct { backend *smtpBackend @@ -129,6 +153,16 @@ type smtpSession struct { email string user *db.User clientIP string + + // 会话日志累积状态 + startedAt time.Time + port int + authTried bool + authOK bool + authUsername string + failReason string // 首个失败原因 + msgCount int // 成功处理的邮件数(本地投递 + 外发队列) + detailParts []string } // AuthMechanisms returns supported SMTP AUTH mechanisms. @@ -136,14 +170,30 @@ func (s *smtpSession) AuthMechanisms() []string { return []string{sasl.Plain} } +// recordFail 记录会话中第一个失败原因(日志用途,不改变协议行为)。 +func (s *smtpSession) recordFail(reason string) { + if s.failReason == "" { + s.failReason = reason + } +} + +// recordDetail 追加一条操作摘要。 +func (s *smtpSession) recordDetail(part string) { + s.detailParts = append(s.detailParts, part) +} + // Auth authenticates the user with SASL PLAIN credentials. func (s *smtpSession) Auth(mech string) (sasl.Server, error) { if mech != sasl.Plain { + s.recordFail("不支持的认证机制") return nil, smtp.ErrAuthUnknownMechanism } return sasl.NewPlainServer(func(identity, username, password string) error { + s.authTried = true + s.authUsername = username // 已封禁 IP 一律拒绝认证(防协议层暴力破解) if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned { + s.recordFail("IP已被封禁") return smtp.ErrAuthFailed } @@ -155,6 +205,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { s.backend.server.banCfg.MaxFailAttempts, s.backend.server.banCfg.BanDurationMin, ) + s.recordFail("用户名或密码错误") return smtp.ErrAuthFailed } @@ -166,10 +217,12 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { } } if domainName == "" { + s.recordFail("用户名或密码错误") return smtp.ErrAuthFailed } s.authenticated = true + s.authOK = true s.userID = user.ID s.user = user s.email = user.Username + "@" + domainName @@ -180,10 +233,12 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { // Mail records the sender address (MAIL FROM command). func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error { if s.mode != smtpModeInbound && !s.authenticated { + s.recordFail("未认证用户尝试发信") return smtp.ErrAuthRequired } // Authenticated users may only send as themselves, preventing spoofing. if s.authenticated && !strings.EqualFold(strings.TrimSpace(from), s.email) { + s.recordFail("发件人地址与登录用户不一致") return fmt.Errorf("sender address must match authenticated user") } @@ -201,6 +256,7 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error { func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error { to = strings.TrimSpace(to) if to == "" { + s.recordFail("无效的收件人地址") return fmt.Errorf("invalid recipient address: %s", to) } @@ -212,12 +268,14 @@ func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error { // External recipient: only authenticated local users may relay. if !s.authenticated { + s.recordFail("中继访问被拒绝") return fmt.Errorf("relay access denied: %s", to) } // Sender verification must have been enforced in Mail() already. ob := s.backend.server.outbound if ob == nil || !ob.Enabled() { + s.recordFail("外部投递未启用") return fmt.Errorf("external delivery is disabled: %s", to) } @@ -235,20 +293,24 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) { // outbound delivery. func (s *smtpSession) Data(r io.Reader) error { if len(s.rcpts) == 0 { + s.recordFail("未指定收件人") return fmt.Errorf("no accepted recipients") } data, err := io.ReadAll(r) if err != nil { + s.recordFail("读取邮件数据失败") return fmt.Errorf("failed to read message data: %w", err) } parsed, err := parseSMTPMessage(data) if err != nil { + s.recordFail("邮件格式解析失败") return err } // Local recipients: deliver to INBOX. + localDelivered := 0 for _, rcpt := range s.localRcpts { user, err := s.localUserByEmail(rcpt) if err != nil { @@ -260,25 +322,33 @@ func (s *smtpSession) Data(r io.Reader) error { continue } log.Printf("SMTP: message delivered to %s", rcpt) + localDelivered++ } + s.msgCount += localDelivered // External recipients: queue for outbound delivery. + externalQueued := 0 if len(s.externalRcpts) > 0 { ob := s.backend.server.outbound if ob == nil { + s.recordFail("外部投递服务不可用") return fmt.Errorf("outbound delivery is unavailable") } maxRcpt := ob.MaxRecipients() if maxRcpt > 0 && len(s.externalRcpts) > maxRcpt { + s.recordFail("外部收件人数量超出限制") return fmt.Errorf("too many external recipients: %d (max %d)", len(s.externalRcpts), maxRcpt) } for _, rcpt := range s.externalRcpts { if _, err := ob.Enqueue(s.user, s.email, rcpt, data); err != nil { + s.recordFail("外发队列投递失败") return fmt.Errorf("failed to queue external recipient %s: %v", rcpt, err) } log.Printf("SMTP: external message queued for %s", rcpt) + externalQueued++ } } + s.msgCount += externalQueued if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound { if err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil { @@ -286,6 +356,8 @@ func (s *smtpSession) Data(r io.Reader) error { } } + s.recordDetail(fmt.Sprintf("MAIL FROM:<%s> RCPT×%d 本地投递%d 外发%d", + s.from, len(s.rcpts), localDelivered, externalQueued)) return nil } @@ -434,5 +506,46 @@ func (s *smtpSession) Reset() { // Logout is called when the SMTP connection is closed. func (s *smtpSession) Logout() error { + s.writeProtocolLog() return nil } + +// writeProtocolLog 汇总本会话状态写入协议调用日志(供后台分析攻击/滥用)。 +func (s *smtpSession) writeProtocolLog() { + success := s.failReason == "" + detail := strings.Join(s.detailParts, "; ") + username := s.authUsername + if username == "" && s.email != "" { + username = s.email + } + if detail == "" { + if s.authTried { + if s.authOK { + detail = "AUTH 成功" + } else { + detail = "AUTH 失败" + } + } else if success { + detail = "连接建立,无邮件操作" + } + } + if success && s.authTried && !s.authOK { + success = false + } + + entry := &db.ProtocolLog{ + Protocol: db.ProtocolSMTP, + Port: s.port, + ClientIP: s.clientIP, + Username: username, + Success: success, + FailReason: s.failReason, + Detail: detail, + MsgCount: s.msgCount, + DurationMs: time.Since(s.startedAt).Milliseconds(), + CreatedAt: time.Now(), + } + if err := s.backend.server.stores.ProtocolLogs.Create(entry); err != nil { + log.Printf("SMTP: 写入协议日志失败: %v", err) + } +} diff --git a/internal/smtp_server/server_test.go b/internal/smtp_server/server_test.go index 37d8b90..440e303 100644 --- a/internal/smtp_server/server_test.go +++ b/internal/smtp_server/server_test.go @@ -4,11 +4,14 @@ import ( "bytes" "fmt" "testing" + "time" + "mail_go/config" "mail_go/internal/db" "mail_go/internal/storage" "mail_go/internal/store" + "github.com/emersion/go-sasl" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -63,7 +66,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) { 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.ProtocolLog{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) @@ -113,3 +116,99 @@ func TestSaveMessagePersistsAttachments(t *testing.T) { t.Fatalf("attachment content mismatch: %q", content) } } + +// TestSessionLoggingRecordsAuthFailure 验证认证失败的会话在 Logout 时写入协议日志。 +func TestSessionLoggingRecordsAuthFailure(t *testing.T) { + 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) + + srv := &SMTPServer{stores: stores, banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30}} + sess := &smtpSession{ + backend: &smtpBackend{server: srv, mode: smtpModeSubmission}, + clientIP: "203.0.113.7", + startedAt: time.Now(), + port: 587, + } + + // 触发一次认证(用户名不存在 → 失败) + mech, err := sess.Auth(sasl.Plain) + if err != nil { + t.Fatalf("Auth: %v", err) + } + // SASL PLAIN 凭据格式: authzid\0authcid\0passwd + if _, _, err := mech.Next([]byte("\x00no-such-user\x00wrong-pass")); err == nil { + t.Fatal("expected auth failure for unknown user") + } + + // 直接调用 Logout 模拟连接结束 + if err := sess.Logout(); err != nil { + t.Fatalf("Logout: %v", err) + } + + logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 1 { + t.Fatalf("expected 1 log, got %d", total) + } + log := logs[0] + if log.Protocol != db.ProtocolSMTP || log.Port != 587 || log.ClientIP != "203.0.113.7" { + t.Fatalf("unexpected log: %+v", log) + } + if log.Success { + t.Fatalf("expected failure, got %+v", log) + } + if log.FailReason == "" { + t.Fatal("expected fail reason") + } + if log.Username != "no-such-user" { + t.Fatalf("username = %q", log.Username) + } +} + +// TestSessionLoggingRecordsDelivery 验证投递成功的会话写入成功日志。 +func TestSessionLoggingRecordsDelivery(t *testing.T) { + 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.8", + startedAt: time.Now(), + port: 25, + rcpts: make([]string, 0), + } + + if err := sess.Mail("sender@example.com", nil); err != nil { + t.Fatalf("Mail: %v", err) + } + if err := sess.Logout(); err != nil { + t.Fatalf("Logout: %v", err) + } + + logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 1 { + t.Fatalf("expected 1 log, got %d", total) + } + if !logs[0].Success { + t.Fatalf("expected success, got %+v", logs[0]) + } +} diff --git a/internal/store/auth_guard_test.go b/internal/store/auth_guard_test.go index d1cf52c..2deaca7 100644 --- a/internal/store/auth_guard_test.go +++ b/internal/store/auth_guard_test.go @@ -18,7 +18,7 @@ func newTestStores(t *testing.T) *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.ProtocolLog{}); err != nil { t.Fatalf("migrate: %v", err) } return NewStores(gdb) diff --git a/internal/store/protocol_log_store.go b/internal/store/protocol_log_store.go new file mode 100644 index 0000000..283a3e5 --- /dev/null +++ b/internal/store/protocol_log_store.go @@ -0,0 +1,139 @@ +package store + +import ( + "time" + + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// ProtocolLogFilter holds optional filters for listing protocol logs. +type ProtocolLogFilter struct { + Protocol string // smtp | imap | pop3,空表示全部 + Success *bool // nil 表示全部 + IP string // 客户端 IP 模糊匹配 + Username string // 用户名模糊匹配 + From time.Time // 起(含) + To time.Time // 止(含) +} + +// ProtocolLogStore defines the interface for protocol call log operations. +type ProtocolLogStore interface { + Create(log *db.ProtocolLog) error + // UpdateDuration 回填会话时长(登录后连接关闭时调用)。 + UpdateDuration(id uint, durationMs int64) error + List(page, size int, filter ProtocolLogFilter) ([]db.ProtocolLog, int64, error) + // CountStats 汇总各协议的失败/成功记录数(用于页面统计卡片)。 + CountStats(from time.Time) (map[string]map[string]int64, error) + // CleanupBefore 删除 created_at 早于 before 的记录。 + CleanupBefore(before time.Time) (int64, error) +} + +// protocolLogStoreGorm implements ProtocolLogStore using GORM. +type protocolLogStoreGorm struct { + db *gorm.DB +} + +// newProtocolLogStore creates a new GORM-backed ProtocolLogStore. +func newProtocolLogStore(database *gorm.DB) ProtocolLogStore { + return &protocolLogStoreGorm{db: database} +} + +// Create inserts a new protocol log record. +func (s *protocolLogStoreGorm) Create(log *db.ProtocolLog) error { + return s.db.Create(log).Error +} + +// UpdateDuration 回填会话时长,仅更新 duration_ms 字段。 +func (s *protocolLogStoreGorm) UpdateDuration(id uint, durationMs int64) error { + return s.db.Model(&db.ProtocolLog{}).Where("id = ?", id).Update("duration_ms", durationMs).Error +} + +// List retrieves a paginated list of protocol logs, newest first. +func (s *protocolLogStoreGorm) List(page, size int, filter ProtocolLogFilter) ([]db.ProtocolLog, int64, error) { + var logs []db.ProtocolLog + var total int64 + + query := s.db.Model(&db.ProtocolLog{}) + query = s.applyFilter(query, filter) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * size + if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&logs).Error; err != nil { + return nil, 0, err + } + return logs, total, nil +} + +func (s *protocolLogStoreGorm) applyFilter(query *gorm.DB, filter ProtocolLogFilter) *gorm.DB { + if filter.Protocol != "" { + query = query.Where("protocol = ?", filter.Protocol) + } + if filter.Success != nil { + query = query.Where("success = ?", *filter.Success) + } + if filter.IP != "" { + query = query.Where("client_ip LIKE ?", "%"+filter.IP+"%") + } + if filter.Username != "" { + query = query.Where("username LIKE ?", "%"+filter.Username+"%") + } + if !filter.From.IsZero() { + query = query.Where("created_at >= ?", filter.From) + } + if !filter.To.IsZero() { + query = query.Where("created_at <= ?", filter.To) + } + return query +} + +// CountStats 返回自 from 以来的记录数,按 protocol 再按 success 分组: +// map[protocol]map[successKey]count。successKey 为 "success"/"fail"。 +func (s *protocolLogStoreGorm) CountStats(from time.Time) (map[string]map[string]int64, error) { + stats := make(map[string]map[string]int64) + for _, proto := range []string{db.ProtocolSMTP, db.ProtocolIMAP, db.ProtocolPOP3} { + stats[proto] = map[string]int64{"success": 0, "fail": 0} + } + + type row struct { + Protocol string + Success bool + Count int64 + } + var rows []row + + query := s.db.Model(&db.ProtocolLog{}). + Select("protocol, success, COUNT(*) AS count"). + Group("protocol, success") + if !from.IsZero() { + query = query.Where("created_at >= ?", from) + } + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + for _, r := range rows { + proto := stats[r.Protocol] + if proto == nil { + proto = map[string]int64{"success": 0, "fail": 0} + stats[r.Protocol] = proto + } + key := "fail" + if r.Success { + key = "success" + } + proto[key] = r.Count + } + return stats, nil +} + +// CleanupBefore deletes records older than the given time and returns the +// number of deleted rows. +func (s *protocolLogStoreGorm) CleanupBefore(before time.Time) (int64, error) { + res := s.db.Where("created_at < ?", before).Delete(&db.ProtocolLog{}) + return res.RowsAffected, res.Error +} diff --git a/internal/store/protocol_log_store_test.go b/internal/store/protocol_log_store_test.go new file mode 100644 index 0000000..a13e983 --- /dev/null +++ b/internal/store/protocol_log_store_test.go @@ -0,0 +1,171 @@ +package store + +import ( + "testing" + "time" + + "mail_go/internal/db" +) + +func TestProtocolLogCreateAndUpdateDuration(t *testing.T) { + s := newTestStores(t) + + entry := &db.ProtocolLog{ + Protocol: db.ProtocolIMAP, + Port: 143, + ClientIP: "203.0.113.9", + Username: "alice", + Success: true, + Detail: "LOGIN 成功", + CreatedAt: time.Now(), + } + if err := s.ProtocolLogs.Create(entry); err != nil { + t.Fatalf("create: %v", err) + } + if entry.ID == 0 { + t.Fatal("expected generated ID") + } + + if err := s.ProtocolLogs.UpdateDuration(entry.ID, 3210); err != nil { + t.Fatalf("update duration: %v", err) + } + + logs, total, err := s.ProtocolLogs.List(1, 10, ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 1 || len(logs) != 1 { + t.Fatalf("expected 1 log, got total=%d len=%d", total, len(logs)) + } + if logs[0].DurationMs != 3210 { + t.Fatalf("duration = %d, want 3210", logs[0].DurationMs) + } + if logs[0].Success != true || logs[0].Username != "alice" { + t.Fatalf("unexpected log: %+v", logs[0]) + } +} + +func TestProtocolLogListFilters(t *testing.T) { + s := newTestStores(t) + + now := time.Now() + entries := []*db.ProtocolLog{ + {Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "10.0.0.1", Username: "", Success: true, FailReason: "", Detail: "投递", CreatedAt: now.Add(-3 * time.Hour)}, + {Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "10.0.0.2", Username: "admin", Success: false, FailReason: "中继访问被拒绝", Detail: "RCPT", CreatedAt: now.Add(-2 * time.Hour)}, + {Protocol: db.ProtocolIMAP, Port: 993, ClientIP: "10.0.0.2", Username: "admin", Success: false, FailReason: "用户名或密码错误", Detail: "LOGIN 失败", CreatedAt: now.Add(-1 * time.Hour)}, + {Protocol: db.ProtocolPOP3, Port: 110, ClientIP: "10.0.0.3", Username: "bob", Success: true, FailReason: "", Detail: "STAT", CreatedAt: now}, + } + for _, e := range entries { + if err := s.ProtocolLogs.Create(e); err != nil { + t.Fatalf("create: %v", err) + } + } + + cases := []struct { + name string + filter ProtocolLogFilter + want int64 + }{ + {"全部", ProtocolLogFilter{}, 4}, + {"按协议", ProtocolLogFilter{Protocol: db.ProtocolSMTP}, 2}, + {"按失败", ProtocolLogFilter{Success: boolPtr(false)}, 2}, + {"按成功", ProtocolLogFilter{Success: boolPtr(true)}, 2}, + {"协议+失败", ProtocolLogFilter{Protocol: db.ProtocolIMAP, Success: boolPtr(false)}, 1}, + {"按IP模糊", ProtocolLogFilter{IP: "10.0.0.2"}, 2}, + {"按用户名", ProtocolLogFilter{Username: "admin"}, 2}, + {"按时间起", ProtocolLogFilter{From: now.Add(-90 * time.Minute)}, 2}, + {"按时间止", ProtocolLogFilter{To: now.Add(-2 * time.Hour)}, 2}, + {"无匹配", ProtocolLogFilter{Protocol: db.ProtocolPOP3, Success: boolPtr(false)}, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, total, err := s.ProtocolLogs.List(1, 50, tc.filter) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != tc.want { + t.Fatalf("total = %d, want %d", total, tc.want) + } + }) + } +} + +func TestProtocolLogListPagination(t *testing.T) { + s := newTestStores(t) + for i := 0; i < 5; i++ { + if err := s.ProtocolLogs.Create(&db.ProtocolLog{ + Protocol: db.ProtocolSMTP, ClientIP: "10.0.0.1", + Success: true, CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("create: %v", err) + } + } + + page1, total, err := s.ProtocolLogs.List(1, 2, ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 5 || len(page1) != 2 { + t.Fatalf("page1 total=%d len=%d", total, len(page1)) + } + // 新记录在前 + if page1[0].ID < page1[1].ID { + t.Fatal("expected newest first") + } + + page3, _, err := s.ProtocolLogs.List(3, 2, ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list page3: %v", err) + } + if len(page3) != 1 { + t.Fatalf("page3 len = %d, want 1", len(page3)) + } +} + +func TestProtocolLogCountStatsAndCleanup(t *testing.T) { + s := newTestStores(t) + + now := time.Now() + entries := []*db.ProtocolLog{ + {Protocol: db.ProtocolSMTP, ClientIP: "a", Success: true, CreatedAt: now.Add(-10 * time.Minute)}, + {Protocol: db.ProtocolSMTP, ClientIP: "b", Success: false, CreatedAt: now.Add(-20 * time.Minute)}, + {Protocol: db.ProtocolIMAP, ClientIP: "c", Success: false, CreatedAt: now.Add(-30 * time.Minute)}, + {Protocol: db.ProtocolIMAP, ClientIP: "d", Success: false, CreatedAt: now.AddDate(0, 0, -40)}, + } + for _, e := range entries { + if err := s.ProtocolLogs.Create(e); err != nil { + t.Fatalf("create: %v", err) + } + } + + stats, err := s.ProtocolLogs.CountStats(now.Add(-24 * time.Hour)) + if err != nil { + t.Fatalf("stats: %v", err) + } + if stats[db.ProtocolSMTP]["success"] != 1 || stats[db.ProtocolSMTP]["fail"] != 1 { + t.Fatalf("smtp stats: %+v", stats[db.ProtocolSMTP]) + } + if stats[db.ProtocolIMAP]["fail"] != 1 { + t.Fatalf("imap fail stats: %+v", stats[db.ProtocolIMAP]) + } + + // 清理 30 天前的记录 + n, err := s.ProtocolLogs.CleanupBefore(now.AddDate(0, 0, -30)) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if n != 1 { + t.Fatalf("deleted = %d, want 1", n) + } + _, total, err := s.ProtocolLogs.List(1, 50, ProtocolLogFilter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 3 { + t.Fatalf("total = %d, want 3", total) + } +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/internal/store/stores.go b/internal/store/stores.go index 5e22861..07eb505 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -8,23 +8,25 @@ import ( // Stores aggregates all store interfaces for convenient access. type Stores struct { - Users UserStore - Mails MailStore - Domains DomainStore - Attachments AttachmentStore - Bans BanStore - Outbound OutboundStore + Users UserStore + Mails MailStore + Domains DomainStore + Attachments AttachmentStore + Bans BanStore + Outbound OutboundStore + ProtocolLogs ProtocolLogStore } // NewStores creates a new Stores instance with all GORM-backed implementations. func NewStores(database *gorm.DB) *Stores { return &Stores{ - Users: newUserStore(database), - Mails: newMailStore(database), - Domains: newDomainStore(database), - Attachments: newAttachmentStore(database), - Bans: newBanStore(database), - Outbound: newOutboundStore(database), + Users: newUserStore(database), + Mails: newMailStore(database), + Domains: newDomainStore(database), + Attachments: newAttachmentStore(database), + Bans: newBanStore(database), + Outbound: newOutboundStore(database), + ProtocolLogs: newProtocolLogStore(database), } } @@ -34,3 +36,4 @@ var _ = db.Domain{} var _ = db.Message{} var _ = db.Attachment{} var _ = db.BanEntry{} +var _ = db.ProtocolLog{} diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index a1b1fea..251cd32 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -30,12 +30,14 @@ type AdminHandler struct { tlsDir string caddyDataDir string outbound *outbound.Manager + // protocolLogKeepDays SMTP/IMAP/POP3 协议日志保留天数(配置文件 [web]) + protocolLogKeepDays int } // NewAdminHandler creates a new AdminHandler with the given stores, attachment // storage, TLS directory, Caddy data directory and outbound delivery manager. -func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager) *AdminHandler { - return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob} +func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int) *AdminHandler { + return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays} } // Dashboard renders the admin dashboard with summary statistics. @@ -773,6 +775,111 @@ func (h *AdminHandler) CleanupBans(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/bans") } +// ListProtocolLogs 渲染协议调用日志页(SMTP/IMAP/POP3 调用记录,支持筛选)。 +func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { + // 页面访问时顺带清理过期日志,避免日志表无限增长 + h.stores.ProtocolLogs.CleanupBefore(time.Now().AddDate(0, 0, -h.protocolLogKeepDays)) + + page := getPageParam(c, "page", 1) + pageSize := 50 + + var success *bool + switch c.Query("success") { + case "success": + v := true + success = &v + case "fail": + v := false + success = &v + } + + from := parseDateQuery(c.Query("from")) + to := parseDateQuery(c.Query("to")) + // 日期选择到天,含当天 + if !to.IsZero() { + to = to.AddDate(0, 0, 1) + } + + filter := store.ProtocolLogFilter{ + Protocol: c.Query("protocol"), + Success: success, + IP: strings.TrimSpace(c.Query("ip")), + Username: strings.TrimSpace(c.Query("username")), + From: from, + To: to, + } + + logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter) + if err != nil { + c.String(http.StatusInternalServerError, "加载协议日志失败: %v", err) + return + } + + // 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用 + dayStart := time.Now().Truncate(24 * time.Hour) + todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart) + allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{}) + normStats := func(m map[string]map[string]int64) map[string]map[string]int { + out := make(map[string]map[string]int, len(m)) + for proto, counts := range m { + out[proto] = map[string]int{"success": int(counts["success"]), "fail": int(counts["fail"])} + } + return out + } + + currentUser, _ := c.Get("currentUser") + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + if totalPages < 1 { + totalPages = 0 + } + + // 分页/筛选链接保留当前筛选条件(URL 编码防止特殊字符破坏链接) + query := map[string]string{ + "protocol": url.QueryEscape(filter.Protocol), + "success": url.QueryEscape(c.Query("success")), + "ip": url.QueryEscape(filter.IP), + "username": url.QueryEscape(filter.Username), + "from": url.QueryEscape(c.Query("from")), + "to": url.QueryEscape(c.Query("to")), + } + + c.HTML(200, "admin_protocol_logs", gin.H{ + "currentUser": currentUser, + "logs": logs, + "total": total, + "page": page, + "pageSize": pageSize, + "totalPages": totalPages, + "filter": query, + "todayStats": normStats(todayStats), + "allStats": normStats(allStats), + "keepDays": h.protocolLogKeepDays, + "activeFolder": "protocol-logs", + }) +} + +// CleanupProtocolLogs 手动清理超出保留天数的协议日志。 +func (h *AdminHandler) CleanupProtocolLogs(c *gin.Context) { + _, _ = h.stores.ProtocolLogs.CleanupBefore(time.Now().AddDate(0, 0, -h.protocolLogKeepDays)) + c.Redirect(http.StatusFound, "/admin/protocol-logs") +} + +// parseDateQuery 解析 YYYY-MM-DD 日期,失败返回零值。 +func parseDateQuery(s string) time.Time { + if s == "" { + return time.Time{} + } + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return time.Time{} + } + return t +} + // ListMails renders the admin mail list page showing all messages across all users. func (h *AdminHandler) ListMails(c *gin.Context) { page := getPageParam(c, "page", 1) diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 6f1b6c8..83439b8 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -68,6 +68,27 @@ func TestRenderAllPages(t *testing.T) { }}, {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, {"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_protocol_logs", ginH{ + "currentUser": user, "activeFolder": "protocol-logs", + "logs": []db.ProtocolLog{ + {ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM: RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now}, + {ID: 2, Protocol: db.ProtocolIMAP, Port: 993, ClientIP: "203.0.113.9", Username: "admin", Success: false, FailReason: "用户名或密码错误", Detail: "LOGIN 失败", DurationMs: 88, CreatedAt: now.Add(-time.Minute)}, + {ID: 3, Protocol: db.ProtocolPOP3, Port: 110, ClientIP: "10.0.0.2", Username: "alice", Success: true, FailReason: "", Detail: "USER PASS STAT RETR×3 QUIT", MsgCount: 3, DurationMs: 500, CreatedAt: now.Add(-2 * time.Minute)}, + }, + "total": 3, "page": 1, "pageSize": 50, "totalPages": 1, + "filter": map[string]string{"protocol": "smtp", "success": "fail", "ip": "203.0.113", "username": "", "from": "2026-08-01", "to": "2026-08-19"}, + "todayStats": map[string]map[string]int{ + db.ProtocolSMTP: {"success": 10, "fail": 2}, + db.ProtocolIMAP: {"success": 5, "fail": 7}, + db.ProtocolPOP3: {"success": 3, "fail": 4}, + }, + "allStats": map[string]map[string]int{ + db.ProtocolSMTP: {"success": 100, "fail": 20}, + db.ProtocolIMAP: {"success": 50, "fail": 70}, + db.ProtocolPOP3: {"success": 30, "fail": 40}, + }, + "keepDays": 30, + }}, } outDir := os.Getenv("MAILGO_PREVIEW_DIR") diff --git a/internal/web/server.go b/internal/web/server.go index 924ff9e..674d55f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -59,7 +59,7 @@ func templateFuncs() template.FuncMap { "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "mul": func(a, b int) int { return a * b }, - "div": func(a, b int) int { return a / b }, + "div": func(a, b int64) int64 { return a / b }, "mod": func(a, b int) int { return a % b }, "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, "seq": func(n int) []int { @@ -228,7 +228,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag func (ws *WebServer) registerRoutes() { authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg) mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound) - adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound) + adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays) // Apply BanMiddleware globally before public routes ws.engine.Use(middleware.BanMiddleware(ws.stores)) @@ -297,6 +297,8 @@ func (ws *WebServer) registerRoutes() { admin.GET("/bans", adminHandler.ListBans) admin.POST("/bans/:id/unban", adminHandler.UnbanIP) admin.POST("/bans/cleanup", adminHandler.CleanupBans) + admin.GET("/protocol-logs", adminHandler.ListProtocolLogs) + admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs) } } diff --git a/internal/web/templates/admin/bans.html b/internal/web/templates/admin/bans.html index 13f71d7..6f42ff2 100644 --- a/internal/web/templates/admin/bans.html +++ b/internal/web/templates/admin/bans.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/dashboard.html b/internal/web/templates/admin/dashboard.html index b87129e..c1569f8 100644 --- a/internal/web/templates/admin/dashboard.html +++ b/internal/web/templates/admin/dashboard.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单

管理后台

diff --git a/internal/web/templates/admin/dns_hint.html b/internal/web/templates/admin/dns_hint.html index 1f35f4f..51e1012 100644 --- a/internal/web/templates/admin/dns_hint.html +++ b/internal/web/templates/admin/dns_hint.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/domain_form.html b/internal/web/templates/admin/domain_form.html index f544245..46742fd 100644 --- a/internal/web/templates/admin/domain_form.html +++ b/internal/web/templates/admin/domain_form.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/domains.html b/internal/web/templates/admin/domains.html index 1b78465..cedc338 100644 --- a/internal/web/templates/admin/domains.html +++ b/internal/web/templates/admin/domains.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/mail_view.html b/internal/web/templates/admin/mail_view.html index bcc81b2..fe9ceb4 100644 --- a/internal/web/templates/admin/mail_view.html +++ b/internal/web/templates/admin/mail_view.html @@ -27,7 +27,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/mails.html b/internal/web/templates/admin/mails.html index d125c5d..e1d3fb2 100644 --- a/internal/web/templates/admin/mails.html +++ b/internal/web/templates/admin/mails.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/outbound.html b/internal/web/templates/admin/outbound.html index 99cbf78..555b8d0 100644 --- a/internal/web/templates/admin/outbound.html +++ b/internal/web/templates/admin/outbound.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单

外发队列

diff --git a/internal/web/templates/admin/protocol_logs.html b/internal/web/templates/admin/protocol_logs.html new file mode 100644 index 0000000..ced23fc --- /dev/null +++ b/internal/web/templates/admin/protocol_logs.html @@ -0,0 +1,131 @@ +{{define "admin_protocol_logs"}} + + + + + + 协议日志 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

协议日志(SMTP / IMAP / POP3)

+
+ +
+
+ +
+
+

{{index .todayStats "smtp" "fail"}}

+

今日 SMTP 失败

+
+
+

{{index .todayStats "imap" "fail"}}

+

今日 IMAP 失败

+
+
+

{{index .todayStats "pop3" "fail"}}

+

今日 POP3 失败

+
+
+

{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}

+

历史失败(全部)

+
+
+ +
+
+
+ + + + + + + + + 重置 +
+
+ + + + + + + + + + + + + + + + + + {{range .logs}} + + + + + + + + + + + + + {{else}} + + {{end}} + +
时间协议端口来源 IP用户名状态失败原因操作摘要消息数时长
{{.CreatedAt.Format "2006-01-02 15:04:05"}} + {{if eq .Protocol "smtp"}}SMTP + {{else if eq .Protocol "imap"}}IMAP + {{else}}POP3{{end}} + {{.Port}}{{.ClientIP}}{{if .Username}}{{.Username}}{{else}}—{{end}} + {{if .Success}}成功 + {{else}}失败{{end}} + {{if .FailReason}}{{.FailReason}}{{else}}—{{end}}{{.Detail}}{{if .MsgCount}}{{.MsgCount}}{{else}}—{{end}}{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}
暂无记录
+ + {{if gt .totalPages 1}} + + {{end}} +
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/user_form.html b/internal/web/templates/admin/user_form.html index 668ce8e..7141e2f 100644 --- a/internal/web/templates/admin/user_form.html +++ b/internal/web/templates/admin/user_form.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/internal/web/templates/admin/users.html b/internal/web/templates/admin/users.html index 0404528..18fb348 100644 --- a/internal/web/templates/admin/users.html +++ b/internal/web/templates/admin/users.html @@ -18,7 +18,8 @@ 用户管理 所有邮件 外发队列 - IP黑名单 + 协议日志 +IP黑名单
diff --git a/main.go b/main.go index a49753a..3585635 100644 --- a/main.go +++ b/main.go @@ -302,10 +302,32 @@ func main() { } }() + // 11. 后台定期清理过期的协议调用日志(SMTP/IMAP/POP3) + startProtocolLogCleaner(stores, cfg.Web.ProtocolLogKeepDays) + fmt.Println("MailGo 邮件系统启动完成") select {} // Block main goroutine } +// startProtocolLogCleaner 每 6 小时清理一次超出保留天数的协议调用日志。 +// keepDays <= 0 表示不清理。 +func startProtocolLogCleaner(stores *store.Stores, keepDays int) { + if keepDays <= 0 { + return + } + go func() { + for { + n, err := stores.ProtocolLogs.CleanupBefore(time.Now().AddDate(0, 0, -keepDays)) + if err != nil { + log.Printf("清理协议日志失败: %v", err) + } else if n > 0 { + log.Printf("已清理 %d 条过期协议日志(保留 %d 天)", n, keepDays) + } + time.Sleep(6 * time.Hour) + } + }() +} + // ensureAdminUser checks if an admin user exists and creates one if not. // It also ensures the default domain "example.com" exists. func ensureAdminUser(stores *store.Stores, cfg *config.Config) { @@ -353,14 +375,14 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) { // Create the admin user adminUser := &db.User{ - Username: "admin", - PasswordHash: string(hashedPassword), - DomainID: domain.ID, - QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB - UsedBytes: 0, - IsActive: true, - IsAdmin: true, - MustChangePassword: true, + Username: "admin", + PasswordHash: string(hashedPassword), + DomainID: domain.ID, + QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB + UsedBytes: 0, + IsActive: true, + IsAdmin: true, + MustChangePassword: true, } if createErr := stores.Users.Create(adminUser); createErr != nil {