feat: 新增 SMTP/IMAP/POP3 协议调用日志(含攻击分析筛选)
- 每个连接记录一条日志:协议、端口、来源 IP、用户名、成功/失败、 失败原因(密码错误/IP封禁/中继被拒/发件人伪造/未认证发信等)、 操作摘要、消息数与会话时长 - 管理后台新增「协议日志」页:按协议/状态/IP/用户名/时间筛选, 今日与历史成功/失败统计卡片,分页查看,可手动清理 - 后台每 6 小时自动清理超出 protocol_log_keep_days(默认30天) 的日志;新增 [web] protocol_log_keep_days 配置项 - 修复 POP3 认证既有 bug:handleUSER 丢弃邮箱域名导致 PASS 永远失败 - 新增 store 单测、SMTP/POP3 端到端测试与模板渲染测试
This commit is contained in:
@@ -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 出站客户端
|
||||
|
||||
+9
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+109
-12
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+15
-12
@@ -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{}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<spam@evil.example> 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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:24px;">管理后台</h2>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" class="active">所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:24px;">外发队列</h2>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
{{define "admin_protocol_logs"}}
|
||||
<!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>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>协议日志(SMTP / IMAP / POP3)</h2>
|
||||
<form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;"
|
||||
onsubmit="return confirm('确认清理 {{.keepDays}} 天前的协议日志?');">
|
||||
<button type="submit" class="btn btn-primary">清理旧日志</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<h3>{{index .todayStats "smtp" "fail"}}</h3>
|
||||
<p>今日 SMTP 失败</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .todayStats "imap" "fail"}}</h3>
|
||||
<p>今日 IMAP 失败</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .todayStats "pop3" "fail"}}</h3>
|
||||
<p>今日 POP3 失败</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
|
||||
<p>历史失败(全部)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;">
|
||||
<select name="protocol" style="padding:6px 10px;">
|
||||
<option value="">全部协议</option>
|
||||
<option value="smtp" {{if eq .filter.protocol "smtp"}}selected{{end}}>SMTP</option>
|
||||
<option value="imap" {{if eq .filter.protocol "imap"}}selected{{end}}>IMAP</option>
|
||||
<option value="pop3" {{if eq .filter.protocol "pop3"}}selected{{end}}>POP3</option>
|
||||
</select>
|
||||
<select name="success" style="padding:6px 10px;">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>成功</option>
|
||||
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>失败</option>
|
||||
</select>
|
||||
<input type="text" name="ip" placeholder="来源 IP(模糊)" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;">
|
||||
<input type="text" name="username" placeholder="用户名(模糊)" value="{{.filter.username}}" style="padding:6px 10px;width:160px;">
|
||||
<input type="date" name="from" value="{{.filter.from}}" style="padding:6px 10px;">
|
||||
<span>至</span>
|
||||
<input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">筛选</button>
|
||||
<a href="/admin/protocol-logs" class="btn btn-sm">重置</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>协议</th>
|
||||
<th>端口</th>
|
||||
<th>来源 IP</th>
|
||||
<th>用户名</th>
|
||||
<th>状态</th>
|
||||
<th>失败原因</th>
|
||||
<th>操作摘要</th>
|
||||
<th>消息数</th>
|
||||
<th>时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .logs}}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>
|
||||
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
|
||||
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
|
||||
{{else}}<span class="badge" style="background:#16a085;color:#fff;">POP3</span>{{end}}
|
||||
</td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.ClientIP}}</td>
|
||||
<td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">成功</span>
|
||||
{{else}}<span class="badge badge-unread">失败</span>{{end}}
|
||||
</td>
|
||||
<td style="max-width:200px;">{{if .FailReason}}{{.FailReason}}{{else}}—{{end}}</td>
|
||||
<td style="max-width:320px;word-break:break-all;font-size:13px;color:#555;">{{.Detail}}</td>
|
||||
<td>{{if .MsgCount}}{{.MsgCount}}{{else}}—{{end}}</td>
|
||||
<td>{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">暂无记录</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}<a href="/admin/protocol-logs?page={{sub .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">上一页</a>{{end}}
|
||||
<span class="current">第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
|
||||
{{if lt .page .totalPages}}<a href="/admin/protocol-logs?page={{add .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">下一页</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user