Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9607c4f023 | ||
|
|
4367f7fb92 | ||
|
|
af839a1378 | ||
|
|
07e81fc328 | ||
|
|
f8fbe1ebdb | ||
|
|
3a4636c2c5 | ||
|
|
07ba01a6f6 | ||
|
|
278c16c36a | ||
|
|
6a9dcd0285 | ||
|
|
ede85e0698 | ||
|
|
b158b8f1f5 | ||
|
|
6d73171207 | ||
|
|
f2493da03e | ||
|
|
353bfa88f2 | ||
|
|
8ea4a623a9 |
@@ -40,3 +40,4 @@ win/srv/
|
||||
|
||||
# 临时测试文件
|
||||
login_test.html
|
||||
migrate
|
||||
@@ -6,11 +6,14 @@ Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 +
|
||||
## 功能特性
|
||||
|
||||
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
|
||||
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
|
||||
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
|
||||
- **Web 邮箱**:QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
|
||||
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
|
||||
- **协议调用日志**:SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
|
||||
- **IMAP 新邮件推送**:本地投递(SMTP/Web 写信)成功后实时推送,挂起 IDLE 的客户端即时收到新邮件通知(无需轮询);其他客户端造成的已读/星标/删除变化也实时同步(IMAP STORE/EXPUNGE、POP3 删除、Web 标已读/删除)
|
||||
- **当前连接监控**:管理后台实时查看 SMTP/IMAP/POP3 活动连接(来源 IP、用户名、TLS、时长),每 5 秒自动刷新;支持「断开并封禁」一键封禁该 IP 全部在线连接(封禁 180 天,可随时解封)
|
||||
- **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭)
|
||||
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
|
||||
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
|
||||
- **多数据库**:默认 SQLite,可切换 MySQL
|
||||
- **跨平台**:Linux 生产部署 + Windows 本地调试
|
||||
|
||||
@@ -87,6 +90,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 明文端口
|
||||
@@ -124,7 +129,9 @@ ldap_use_tls = false
|
||||
|
||||
[ban]
|
||||
max_fail_attempts = 5 # 登录失败次数阈值
|
||||
ban_duration_min = 30 # 封禁时长(分钟)
|
||||
ban_duration_min = 30 # 第 1 次封禁时长(分钟);之后按档位递增:
|
||||
# 第 2 次 3 小时 → 第 3 次 3 个月 → 第 4 次起半年(上限)
|
||||
# 前 3 次达到阈值只计数不封禁,成功登录后清零
|
||||
|
||||
[caddy]
|
||||
data_dir = "" # Caddy 数据目录(含 certificates/ 的那个),
|
||||
@@ -134,6 +141,11 @@ data_dir = "" # Caddy 数据目录(含 certificate
|
||||
[outbound]
|
||||
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
|
||||
poll_interval = 15 # 外发队列扫描间隔(秒)
|
||||
workers = 4 # 并发投递 worker 数(多线程并行发送,
|
||||
# 大量邮件时吞吐提升;0/1 为串行)
|
||||
batch_size = 50 # 每次扫描最多取出的待投递邮件数
|
||||
max_concurrent_per_domain = 2 # 同一收件域(或中继)的最大并发连接数,
|
||||
# 防被判定为滥发;0 表示不限制
|
||||
max_attempts = 12 # 单封邮件最大投递尝试次数
|
||||
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
|
||||
max_recipients = 50 # 单封邮件最大外部收件人数
|
||||
@@ -383,7 +395,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 出站客户端
|
||||
@@ -393,6 +406,7 @@ mailgo/
|
||||
│ │ ├── server.go # IMAP 服务
|
||||
│ │ └── backend.go # IMAP 后端
|
||||
│ ├── pop3_server/server.go # POP3 服务
|
||||
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
|
||||
│ ├── storage/attachment.go # 附件文件存储
|
||||
│ ├── dkim/keys.go # DKIM 密钥生成
|
||||
│ ├── auth/
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// migrate 一次性工具:把 SQLite 数据迁移到 MySQL(mailgo 库)。
|
||||
// 用法:go run ./cmd/migrate -from /srv/mail_go/mail.db -dsn "mailgo:密码@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=UTC"
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
fromDSN = flag.String("from", "/srv/mail_go/mail.db", "SQLite 数据库路径")
|
||||
mysqlDSN = flag.String("dsn", "", "MySQL DSN(目标库,需已创建 mailgo 库与用户)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *mysqlDSN == "" {
|
||||
log.Fatal("缺少 -dsn")
|
||||
}
|
||||
|
||||
// 目标:MySQL(InitDB 内含 AutoMigrate,按当前模型建表)
|
||||
mdb, err := db.InitDB(config.DatabaseConfig{Driver: "mysql", DSN: *mysqlDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
|
||||
if err != nil {
|
||||
log.Fatalf("连接 MySQL 失败: %v", err)
|
||||
}
|
||||
log.Println("MySQL 建表完成(AutoMigrate)")
|
||||
|
||||
// 源:SQLite(只读)
|
||||
sdb, err := db.InitDB(config.DatabaseConfig{Driver: "sqlite", DSN: *fromDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
|
||||
if err != nil {
|
||||
log.Fatalf("连接 SQLite 失败: %v", err)
|
||||
}
|
||||
sdb.Logger = logger.Default.LogMode(logger.Silent)
|
||||
|
||||
// 关闭 GORM 自动时间戳(保留原始 CreatedAt/UpdatedAt)
|
||||
mw := mdb.Session(&gorm.Session{SkipHooks: true})
|
||||
|
||||
stateWant := int64(-1) // mailbox_states 期望行数;-1 = 源库无此表不校验
|
||||
|
||||
// 按外键依赖顺序复制:domains → users → messages → attachments → 其余
|
||||
// 所有时间统一 UTC(MySQL DATETIME 无时区)。
|
||||
utc := func(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
// MySQL DATETIME 最小年份 1000;零值由调用方转 NULL
|
||||
return t
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
_ = utc
|
||||
|
||||
// ---- domains ----
|
||||
var domains []db.Domain
|
||||
if err := sdb.Order("id").Find(&domains).Error; err != nil {
|
||||
log.Fatalf("读 domains: %v", err)
|
||||
}
|
||||
for i := range domains {
|
||||
domains[i].CreatedAt = domains[i].CreatedAt.UTC()
|
||||
domains[i].UpdatedAt = domains[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&domains).Error; err != nil {
|
||||
log.Fatalf("写 domains: %v", err)
|
||||
}
|
||||
log.Printf("domains: %d", len(domains))
|
||||
|
||||
// ---- users ----
|
||||
var users []db.User
|
||||
if err := sdb.Order("id").Find(&users).Error; err != nil {
|
||||
log.Fatalf("读 users: %v", err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].CreatedAt = users[i].CreatedAt.UTC()
|
||||
users[i].UpdatedAt = users[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&users).Error; err != nil {
|
||||
log.Fatalf("写 users: %v", err)
|
||||
}
|
||||
log.Printf("users: %d", len(users))
|
||||
|
||||
// ---- messages ----
|
||||
var msgs []db.Message
|
||||
if err := sdb.Order("id").Find(&msgs).Error; err != nil {
|
||||
log.Fatalf("读 messages: %v", err)
|
||||
}
|
||||
for i := range msgs {
|
||||
msgs[i].Date = msgs[i].Date.UTC()
|
||||
msgs[i].CreatedAt = msgs[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&msgs).Error; err != nil {
|
||||
log.Fatalf("写 messages: %v", err)
|
||||
}
|
||||
log.Printf("messages: %d", len(msgs))
|
||||
|
||||
// ---- attachments ----
|
||||
var atts []db.Attachment
|
||||
if err := sdb.Order("id").Find(&atts).Error; err != nil {
|
||||
log.Fatalf("读 attachments: %v", err)
|
||||
}
|
||||
for i := range atts {
|
||||
atts[i].CreatedAt = atts[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&atts).Error; err != nil {
|
||||
log.Fatalf("写 attachments: %v", err)
|
||||
}
|
||||
log.Printf("attachments: %d", len(atts))
|
||||
|
||||
// ---- outbound_messages(原样,含时间转 UTC)----
|
||||
var outs []db.OutboundMessage
|
||||
if err := sdb.Order("id").Find(&outs).Error; err != nil {
|
||||
log.Fatalf("读 outbound_messages: %v", err)
|
||||
}
|
||||
for i := range outs {
|
||||
outs[i].NextAttemptAt = outs[i].NextAttemptAt.UTC()
|
||||
if outs[i].CompletedAt != nil && !outs[i].CompletedAt.IsZero() {
|
||||
u := outs[i].CompletedAt.UTC()
|
||||
outs[i].CompletedAt = &u
|
||||
}
|
||||
outs[i].CreatedAt = outs[i].CreatedAt.UTC()
|
||||
outs[i].UpdatedAt = outs[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&outs).Error; err != nil {
|
||||
log.Fatalf("写 outbound_messages: %v", err)
|
||||
}
|
||||
log.Printf("outbound_messages: %d", len(outs))
|
||||
|
||||
// ---- ban_entries(expires_at 零值 → NULL)----
|
||||
rows, err := sdb.Raw("SELECT id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at FROM ban_entries ORDER BY id").Rows()
|
||||
if err != nil {
|
||||
log.Fatalf("读 ban_entries: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
bans := 0
|
||||
for rows.Next() {
|
||||
var (
|
||||
id uint
|
||||
ip string
|
||||
reason *string
|
||||
failCount int
|
||||
banCount int
|
||||
expires *time.Time
|
||||
created *time.Time
|
||||
updated *time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &ip, &reason, &failCount, &banCount, &expires, &created, &updated); err != nil {
|
||||
log.Fatalf("扫 ban_entries: %v", err)
|
||||
}
|
||||
norm := func(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
if err := mdb.Exec("INSERT INTO ban_entries (id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
id, ip, reason, failCount, banCount, norm(expires), norm(created), norm(updated)).Error; err != nil {
|
||||
log.Fatalf("写 ban_entries id=%d: %v", id, err)
|
||||
}
|
||||
bans++
|
||||
}
|
||||
log.Printf("ban_entries: %d", bans)
|
||||
|
||||
// ---- protocol_logs ----
|
||||
var logs []db.ProtocolLog
|
||||
if err := sdb.Order("id").Find(&logs).Error; err != nil {
|
||||
log.Fatalf("读 protocol_logs: %v", err)
|
||||
}
|
||||
for i := range logs {
|
||||
logs[i].CreatedAt = logs[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&logs).Error; err != nil {
|
||||
log.Fatalf("写 protocol_logs: %v", err)
|
||||
}
|
||||
log.Printf("protocol_logs: %d", len(logs))
|
||||
|
||||
// ---- mailbox_states(原生 SQL:该表随 UIDVALIDITY 特性存在,旧版本源库可能没有)----
|
||||
var stateCount int64
|
||||
sdb.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mailbox_states'").Scan(&stateCount)
|
||||
if stateCount > 0 {
|
||||
// 目标库若没有该表(上游模型未含 MailboxState 时 AutoMigrate 不会建),先建表
|
||||
var tcnt int64
|
||||
mdb.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'mailbox_states'").Scan(&tcnt)
|
||||
if tcnt == 0 {
|
||||
if err := mdb.Exec(`CREATE TABLE mailbox_states (
|
||||
user_id bigint unsigned NOT NULL,
|
||||
folder varchar(64) NOT NULL,
|
||||
uid_validity bigint unsigned NOT NULL,
|
||||
created_at datetime(3) NULL,
|
||||
updated_at datetime(3) NULL,
|
||||
PRIMARY KEY (user_id, folder))`).Error; err != nil {
|
||||
log.Fatalf("建 mailbox_states 表: %v", err)
|
||||
}
|
||||
log.Println("mailbox_states: 目标库已建表")
|
||||
}
|
||||
srows, err := sdb.Raw("SELECT user_id, folder, uid_validity, created_at, updated_at FROM mailbox_states ORDER BY user_id, folder").Rows()
|
||||
if err != nil {
|
||||
log.Fatalf("读 mailbox_states: %v", err)
|
||||
}
|
||||
defer srows.Close()
|
||||
states := 0
|
||||
for srows.Next() {
|
||||
var (
|
||||
userID uint
|
||||
folder string
|
||||
validity uint32
|
||||
created *time.Time
|
||||
updated *time.Time
|
||||
)
|
||||
if err := srows.Scan(&userID, &folder, &validity, &created, &updated); err != nil {
|
||||
log.Fatalf("扫 mailbox_states: %v", err)
|
||||
}
|
||||
norm := func(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
if err := mdb.Exec("INSERT INTO mailbox_states (user_id, folder, uid_validity, created_at, updated_at) VALUES (?,?,?,?,?)",
|
||||
userID, folder, validity, norm(created), norm(updated)).Error; err != nil {
|
||||
log.Fatalf("写 mailbox_states: %v", err)
|
||||
}
|
||||
states++
|
||||
}
|
||||
log.Printf("mailbox_states: %d", states)
|
||||
stateWant = int64(states)
|
||||
} else {
|
||||
log.Println("mailbox_states: 源库无此表,跳过")
|
||||
}
|
||||
|
||||
// ---- 校验 ----
|
||||
check := func(table string, want int64) {
|
||||
var got int64
|
||||
if err := mdb.Table(table).Count(&got).Error; err != nil {
|
||||
log.Fatalf("校验 %s: %v", table, err)
|
||||
}
|
||||
if got != want {
|
||||
log.Fatalf("校验 %s 失败: got %d want %d", table, got, want)
|
||||
}
|
||||
fmt.Printf("校验 %s: %d/%d ✓\n", table, got, want)
|
||||
}
|
||||
check("domains", int64(len(domains)))
|
||||
check("users", int64(len(users)))
|
||||
check("messages", int64(len(msgs)))
|
||||
check("attachments", int64(len(atts)))
|
||||
check("outbound_messages", int64(len(outs)))
|
||||
check("ban_entries", int64(bans))
|
||||
check("protocol_logs", int64(len(logs)))
|
||||
if stateWant >= 0 {
|
||||
check("mailbox_states", stateWant)
|
||||
}
|
||||
|
||||
log.Println("迁移完成 ✅")
|
||||
}
|
||||
+40
-12
@@ -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 是覆盖会话签名密钥的环境变量名。
|
||||
@@ -123,6 +126,15 @@ type OutboundConfig struct {
|
||||
MaxPerDay int `toml:"max_per_day"` // 每用户每日最大外发数,0 表示禁用外部投递
|
||||
ConnectTimeout int `toml:"connect_timeout"` // 连接远程 MX 超时(秒)
|
||||
|
||||
// Workers 并发投递 worker 数:多 goroutine 并行发送队列中的邮件。
|
||||
// 0 或 1 表示串行(旧行为)。
|
||||
Workers int `toml:"workers"`
|
||||
// BatchSize 每次扫描最多取出的待投递邮件数。
|
||||
BatchSize int `toml:"batch_size"`
|
||||
// MaxConcurrentPerDomain 同一收件域(或中继)的最大并发连接数,
|
||||
// 防止对单个 MX 域并发过多而被判定为滥发;0 表示不限制。
|
||||
MaxConcurrentPerDomain int `toml:"max_concurrent_per_domain"`
|
||||
|
||||
// Smarthost relay: when relay_host is non-empty, all external mail is
|
||||
// delivered through this relay instead of direct MX delivery. Useful when
|
||||
// the server IP is listed in PBL/blocklists (residential/dynamic IPs).
|
||||
@@ -197,8 +209,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),
|
||||
@@ -226,16 +239,19 @@ func defaultConfig() *Config {
|
||||
// Caddy: 留空则自动探测常见数据目录,无需配置
|
||||
Caddy: CaddyConfig{},
|
||||
Outbound: OutboundConfig{
|
||||
PollInterval: 15, // 15 秒扫描一次队列
|
||||
MaxAttempts: 12, // 最多尝试 12 次
|
||||
RetryBaseMin: 5, // 5/10/20/40/... 分钟指数退避
|
||||
MaxRecipients: 50, // 单封最多 50 个外部收件人
|
||||
MaxPerMin: 30, // 每用户每分钟 30 封
|
||||
MaxPerDay: 500,
|
||||
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
|
||||
RelayPort: 587, // smarthost 默认提交端口
|
||||
RelayStartTLS: true,
|
||||
IPFamily: "ipv4",
|
||||
PollInterval: 15, // 15 秒扫描一次队列
|
||||
MaxAttempts: 12, // 最多尝试 12 次
|
||||
RetryBaseMin: 5, // 5/10/20/40/... 分钟指数退避
|
||||
MaxRecipients: 50, // 单封最多 50 个外部收件人
|
||||
MaxPerMin: 30, // 每用户每分钟 30 封
|
||||
MaxPerDay: 500,
|
||||
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
|
||||
RelayPort: 587, // smarthost 默认提交端口
|
||||
RelayStartTLS: true,
|
||||
IPFamily: "ipv4",
|
||||
Workers: DefaultOutboundWorkers,
|
||||
BatchSize: DefaultOutboundBatchSize,
|
||||
MaxConcurrentPerDomain: DefaultMaxConcurrentPerDomain,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -262,6 +278,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
|
||||
}
|
||||
@@ -318,6 +337,15 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
|
||||
if cfg.Outbound.ConnectTimeout == 0 {
|
||||
cfg.Outbound.ConnectTimeout = defaults.Outbound.ConnectTimeout
|
||||
}
|
||||
if cfg.Outbound.Workers == 0 {
|
||||
cfg.Outbound.Workers = defaults.Outbound.Workers
|
||||
}
|
||||
if cfg.Outbound.BatchSize == 0 {
|
||||
cfg.Outbound.BatchSize = defaults.Outbound.BatchSize
|
||||
}
|
||||
if cfg.Outbound.MaxConcurrentPerDomain == 0 {
|
||||
cfg.Outbound.MaxConcurrentPerDomain = defaults.Outbound.MaxConcurrentPerDomain
|
||||
}
|
||||
if cfg.Outbound.RelayPort == 0 {
|
||||
cfg.Outbound.RelayPort = defaults.Outbound.RelayPort
|
||||
}
|
||||
|
||||
@@ -36,5 +36,18 @@ const (
|
||||
DefaultQuotaBytes int64 = 5 * 1024 * 1024 * 1024 // 5GB
|
||||
)
|
||||
|
||||
// DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。
|
||||
const DefaultProtocolLogKeepDays = 30
|
||||
|
||||
// Outbound delivery concurrency defaults.
|
||||
const (
|
||||
// DefaultOutboundWorkers 并发投递 worker 数(0/1 为串行)。
|
||||
DefaultOutboundWorkers = 4
|
||||
// DefaultOutboundBatchSize 每次扫描最多取出的待投递邮件数。
|
||||
DefaultOutboundBatchSize = 50
|
||||
// DefaultMaxConcurrentPerDomain 同一收件域的最大并发连接数。
|
||||
DefaultMaxConcurrentPerDomain = 2
|
||||
)
|
||||
|
||||
// ConfigFileName is the name of the configuration file
|
||||
const ConfigFileName = "mail_go.toml"
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package connhub 提供邮件协议(SMTP/IMAP/POP3)当前活动连接的注册中心,
|
||||
// 供管理后台实时查看连接情况(来源 IP、用户、TLS、时长等)。
|
||||
package connhub
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Conn 表示一个活动中的协议连接。字段由 Hub 的锁保护。
|
||||
type Conn struct {
|
||||
ID uint64 // 自增序号
|
||||
Protocol string // smtp | imap | pop3
|
||||
IP string
|
||||
Port int
|
||||
User string // 认证后填充
|
||||
TLS bool
|
||||
Connected time.Time
|
||||
LastActive time.Time
|
||||
|
||||
hub *Hub
|
||||
// disconnect 强制断开底层连接的回调(由各协议服务器注册)。
|
||||
// 关闭底层 socket 后协议服务器会正常走收尾清理(Logout/注销)。
|
||||
disconnect func()
|
||||
}
|
||||
|
||||
// Hub 管理所有活动连接(同一把锁保护注册表与连接字段)。
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
conns map[uint64]*Conn
|
||||
}
|
||||
|
||||
// New 创建连接注册中心。
|
||||
func New() *Hub {
|
||||
return &Hub{conns: make(map[uint64]*Conn)}
|
||||
}
|
||||
|
||||
// Register 注册一个新连接并返回其句柄;调用方在连接结束时调用 Close()。
|
||||
func (h *Hub) Register(protocol, ip string, port int, tls bool) *Conn {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.seq++
|
||||
now := time.Now()
|
||||
c := &Conn{
|
||||
ID: h.seq,
|
||||
Protocol: protocol,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
TLS: tls,
|
||||
Connected: now,
|
||||
LastActive: now,
|
||||
hub: h,
|
||||
}
|
||||
h.conns[c.ID] = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetUser 记录认证成功的用户名(邮箱)并刷新最后活跃时间。
|
||||
func (c *Conn) SetUser(u string) {
|
||||
if c == nil || c.hub == nil {
|
||||
return
|
||||
}
|
||||
c.hub.mu.Lock()
|
||||
c.User = u
|
||||
c.LastActive = time.Now()
|
||||
c.hub.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetTLS 更新连接的 TLS 状态(如 POP3 STLS 升级之后)。
|
||||
func (c *Conn) SetTLS(on bool) {
|
||||
if c == nil || c.hub == nil {
|
||||
return
|
||||
}
|
||||
c.hub.mu.Lock()
|
||||
c.TLS = on
|
||||
c.hub.mu.Unlock()
|
||||
}
|
||||
|
||||
// Touch 刷新最后活跃时间。
|
||||
func (c *Conn) Touch() {
|
||||
if c == nil || c.hub == nil {
|
||||
return
|
||||
}
|
||||
c.hub.mu.Lock()
|
||||
c.LastActive = time.Now()
|
||||
c.hub.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetDisconnect 注册强制断开底层连接的回调(管理后台「断开并封禁」用)。
|
||||
func (c *Conn) SetDisconnect(fn func()) {
|
||||
if c == nil || c.hub == nil {
|
||||
return
|
||||
}
|
||||
c.hub.mu.Lock()
|
||||
c.disconnect = fn
|
||||
c.hub.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close 从注册中心移除该连接。
|
||||
func (c *Conn) Close() {
|
||||
if c == nil || c.hub == nil {
|
||||
return
|
||||
}
|
||||
c.hub.mu.Lock()
|
||||
delete(c.hub.conns, c.ID)
|
||||
c.hub.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get 按 ID 查找活动连接。
|
||||
func (h *Hub) Get(id uint64) (*Conn, bool) {
|
||||
if h == nil {
|
||||
return nil, false
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c, ok := h.conns[id]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// Disconnect 强制断开指定连接(关闭底层连接并注销)。
|
||||
func (h *Hub) Disconnect(id uint64) bool {
|
||||
c, ok := h.Get(id)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
fn := c.disconnect
|
||||
h.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DisconnectByIP 强制断开该 IP 的全部连接,返回断开的连接数。
|
||||
// 用于封禁 IP 后立即踢掉其所有在线会话。
|
||||
func (h *Hub) DisconnectByIP(ip string) int {
|
||||
if h == nil || ip == "" {
|
||||
return 0
|
||||
}
|
||||
h.mu.Lock()
|
||||
var fns []func()
|
||||
for _, c := range h.conns {
|
||||
if c.IP == ip && c.disconnect != nil {
|
||||
fns = append(fns, c.disconnect)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, fn := range fns {
|
||||
fn()
|
||||
}
|
||||
return len(fns)
|
||||
}
|
||||
|
||||
// List 返回当前所有活动连接(拷贝),按连接时间升序。
|
||||
func (h *Hub) List() []Conn {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
out := make([]Conn, 0, len(h.conns))
|
||||
for _, c := range h.conns {
|
||||
out = append(out, Conn{
|
||||
ID: c.ID,
|
||||
Protocol: c.Protocol,
|
||||
IP: c.IP,
|
||||
Port: c.Port,
|
||||
User: c.User,
|
||||
TLS: c.TLS,
|
||||
Connected: c.Connected,
|
||||
LastActive: c.LastActive,
|
||||
})
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Counts 返回按协议分组的当前连接数。
|
||||
func (h *Hub) Counts() map[string]int {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
counts := make(map[string]int)
|
||||
for _, c := range h.conns {
|
||||
counts[c.Protocol]++
|
||||
}
|
||||
return counts
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package connhub
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRegisterListCountClose(t *testing.T) {
|
||||
h := New()
|
||||
|
||||
c1 := h.Register("smtp", "10.0.0.1", 25, false)
|
||||
c2 := h.Register("imap", "10.0.0.2", 993, true)
|
||||
c3 := h.Register("pop3", "10.0.0.3", 110, false)
|
||||
|
||||
if n := len(h.List()); n != 3 {
|
||||
t.Fatalf("list len = %d, want 3", n)
|
||||
}
|
||||
counts := h.Counts()
|
||||
if counts["smtp"] != 1 || counts["imap"] != 1 || counts["pop3"] != 1 {
|
||||
t.Fatalf("counts = %v", counts)
|
||||
}
|
||||
|
||||
c2.SetUser("alice@example.com")
|
||||
c1.SetTLS(true)
|
||||
time.Sleep(time.Millisecond)
|
||||
c3.Touch()
|
||||
|
||||
// 用户名 / TLS 状态生效
|
||||
var imapUser string
|
||||
for _, c := range h.List() {
|
||||
if c.Protocol == "imap" {
|
||||
imapUser = c.User
|
||||
}
|
||||
if c.Protocol == "smtp" && !c.TLS {
|
||||
t.Fatal("smtp conn should be TLS after SetTLS(true)")
|
||||
}
|
||||
if c.Protocol == "pop3" && !c.LastActive.After(c.Connected) {
|
||||
t.Fatal("pop3 conn LastActive should be after Connected after Touch")
|
||||
}
|
||||
}
|
||||
if imapUser != "alice@example.com" {
|
||||
t.Fatalf("imap user = %q", imapUser)
|
||||
}
|
||||
|
||||
c1.Close()
|
||||
c2.Close()
|
||||
if n := len(h.List()); n != 1 {
|
||||
t.Fatalf("after close, len = %d, want 1", n)
|
||||
}
|
||||
if h.Counts()["imap"] != 0 {
|
||||
t.Fatalf("imap count after close = %d, want 0", h.Counts()["imap"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilHubSafe(t *testing.T) {
|
||||
var h *Hub
|
||||
if c := h.Register("smtp", "1.2.3.4", 25, false); c != nil {
|
||||
t.Fatal("nil hub Register must return nil")
|
||||
}
|
||||
if h.List() != nil || h.Counts() != nil {
|
||||
t.Fatal("nil hub List/Counts must be nil")
|
||||
}
|
||||
var c *Conn
|
||||
c.SetUser("x") // 不应 panic
|
||||
c.Touch()
|
||||
c.SetTLS(true)
|
||||
c.Close()
|
||||
}
|
||||
|
||||
func TestConcurrentRegisterClose(t *testing.T) {
|
||||
h := New()
|
||||
const n = 50
|
||||
var wg sync.WaitGroup
|
||||
conns := make([]*Conn, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
conns[i] = h.Register("smtp", "10.0.0.1", 25, false)
|
||||
conns[i].SetUser("u")
|
||||
conns[i].Touch()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(h.List()) != n {
|
||||
t.Fatalf("len = %d, want %d", len(h.List()), n)
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
conns[i].Close()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if len(h.List()) != 0 {
|
||||
t.Fatalf("after concurrent close, len = %d, want 0", len(h.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrderedByID(t *testing.T) {
|
||||
h := New()
|
||||
h.Register("smtp", "1.1.1.1", 25, false)
|
||||
h.Register("imap", "2.2.2.2", 143, false)
|
||||
h.Register("pop3", "3.3.3.3", 110, false)
|
||||
list := h.List()
|
||||
for i := 1; i < len(list); i++ {
|
||||
if list[i].ID <= list[i-1].ID {
|
||||
t.Fatalf("list not ordered by ID: %+v", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnect 验证强制断开回调被调用。
|
||||
func TestDisconnect(t *testing.T) {
|
||||
h := New()
|
||||
var closed atomic.Int32
|
||||
|
||||
c1 := h.Register("smtp", "10.0.0.1", 25, false)
|
||||
c1.SetDisconnect(func() { closed.Add(1) })
|
||||
c2 := h.Register("imap", "10.0.0.2", 993, true)
|
||||
c2.SetDisconnect(func() { closed.Add(1) })
|
||||
|
||||
if !h.Disconnect(c1.ID) {
|
||||
t.Fatal("Disconnect should report success")
|
||||
}
|
||||
if closed.Load() != 1 {
|
||||
t.Fatalf("closed = %d, want 1", closed.Load())
|
||||
}
|
||||
// 已断开(未注销)仍可查到
|
||||
if _, ok := h.Get(c1.ID); !ok {
|
||||
t.Fatal("conn should still be registered until Close")
|
||||
}
|
||||
// 不存在的 ID
|
||||
if h.Disconnect(99999) {
|
||||
t.Fatal("Disconnect of unknown id must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectByIP 验证封禁时断开该 IP 全部连接。
|
||||
func TestDisconnectByIP(t *testing.T) {
|
||||
h := New()
|
||||
var closed atomic.Int32
|
||||
|
||||
// 同一 IP 三个协议连接
|
||||
for _, proto := range []string{"smtp", "imap", "pop3"} {
|
||||
c := h.Register(proto, "203.0.113.5", 25, false)
|
||||
c.SetDisconnect(func() { closed.Add(1) })
|
||||
}
|
||||
// 另一 IP 不受影响
|
||||
other := h.Register("smtp", "203.0.113.6", 25, false)
|
||||
other.SetDisconnect(func() { closed.Add(1) })
|
||||
|
||||
n := h.DisconnectByIP("203.0.113.5")
|
||||
if n != 3 {
|
||||
t.Fatalf("disconnected = %d, want 3", n)
|
||||
}
|
||||
if closed.Load() != 3 {
|
||||
t.Fatalf("closed = %d, want 3", closed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectNoCallback 验证未注册断开回调的连接安全跳过。
|
||||
func TestDisconnectNoCallback(t *testing.T) {
|
||||
h := New()
|
||||
c := h.Register("pop3", "10.0.0.9", 110, false)
|
||||
if !h.Disconnect(c.ID) {
|
||||
t.Fatal("Disconnect should report success even without callback")
|
||||
}
|
||||
if n := h.DisconnectByIP("10.0.0.9"); n != 0 {
|
||||
t.Fatalf("disconnected = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"mail_go/config"
|
||||
|
||||
@@ -31,6 +32,15 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据库目录失败 %s: %w", dir, err)
|
||||
}
|
||||
// 多连接并发(SMTP/IMAP/POP3/Web/外发 worker/推送)下:
|
||||
// - WAL 模式:读不阻塞写,消除瞬时 SQLITE_BUSY 导致写失败被吞
|
||||
// - busy_timeout=5000ms:写竞争时等待而非立刻失败
|
||||
// - synchronous=NORMAL:WAL 下安全且写入更快
|
||||
sep := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
dsn = dsn + sep + "_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
dialector = sqlite.Open(dsn)
|
||||
case "mysql":
|
||||
dialector = mysql.Open(cfg.DSN)
|
||||
@@ -46,7 +56,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
}
|
||||
|
||||
// Auto-migrate all models
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+36
-3
@@ -60,8 +60,8 @@ type Message struct {
|
||||
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
||||
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
||||
Subject string `gorm:"size:1024" json:"subject"`
|
||||
TextBody string `gorm:"type:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" json:"html_body"`
|
||||
TextBody string `gorm:"type:mediumtext" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
|
||||
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||
@@ -114,6 +114,10 @@ type BanEntry struct {
|
||||
IPAddress string `gorm:"size:45;index;not null" json:"ip_address"`
|
||||
Reason string `gorm:"size:255" json:"reason"`
|
||||
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
|
||||
// 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。
|
||||
// 成功登录或管理员解封会删除记录,次数随之清零。
|
||||
BanCount int `gorm:"default:0" json:"ban_count"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -122,11 +126,40 @@ 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.
|
||||
// 注意:不声明 Message 关联(避免 GORM 外键名 MessageID 与
|
||||
// Message.MessageID 字符串字段冲突,导致 AutoMigrate 生成错误外键)。
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MessageID uint `gorm:"index;not null" json:"message_id"`
|
||||
Message Message `gorm:"foreignKey:MessageID" json:"message"`
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"`
|
||||
FilePath string `gorm:"size:512;not null" json:"file_path"`
|
||||
ContentType string `gorm:"size:128" json:"content_type"`
|
||||
|
||||
+252
-13
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/store"
|
||||
@@ -24,25 +25,131 @@ import (
|
||||
|
||||
// ---------- imapBackend ----------
|
||||
|
||||
// imapBackend implements backend.Backend.
|
||||
// imapBackend implements backend.Backend and backend.BackendUpdater.
|
||||
type imapBackend struct {
|
||||
stores *store.Stores
|
||||
banCfg config.BanConfig
|
||||
port int
|
||||
hub *connhub.Hub
|
||||
|
||||
// updates 承载新邮件等后端更新,由 go-imap 服务器广播给相关客户端。
|
||||
updates chan backend.Update
|
||||
// disconnectAddr 强制断开指定远端地址的连接(管理后台断开封禁用)。
|
||||
disconnectAddr func(addr string)
|
||||
}
|
||||
|
||||
// Updates 实现 backend.BackendUpdater:新邮件推送通道(广播按用户名与
|
||||
// 邮箱过滤,只送达已选中对应邮箱的客户端)。
|
||||
func (b *imapBackend) Updates() <-chan backend.Update {
|
||||
return b.updates
|
||||
}
|
||||
|
||||
// buildNewMessageUpdate 为一条新投递到 mailbox 的邮件构造 IMAP 更新。
|
||||
// seq 取该邮件在邮箱中的实际序号(最新在前,通常为 1)。
|
||||
func buildNewMessageUpdate(stores *store.Stores, userEmail, mailbox string, msg *db.Message) *backend.MessageUpdate {
|
||||
if stores == nil || msg == nil || userEmail == "" || mailbox == "" {
|
||||
return nil
|
||||
}
|
||||
seq := seqOf(stores, msg.UserID, mailbox, msg.ID)
|
||||
if seq == 0 {
|
||||
seq = 1
|
||||
}
|
||||
|
||||
imapMsg := imap.NewMessage(seq, []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchEnvelope})
|
||||
imapMsg.Uid = uint32(msg.ID)
|
||||
imapMsg.Flags = flagsOf(msg.IsRead, msg.IsFlagged, false)
|
||||
imapMsg.InternalDate = msg.Date
|
||||
imapMsg.Size = uint32(len(msg.RawData))
|
||||
imapMsg.Envelope = &imap.Envelope{
|
||||
Date: msg.Date,
|
||||
Subject: msg.Subject,
|
||||
From: parseAddressList(msg.FromAddr),
|
||||
Sender: parseAddressList(msg.FromAddr),
|
||||
ReplyTo: parseAddressList(msg.FromAddr),
|
||||
To: parseAddressList(msg.ToAddr),
|
||||
Cc: parseAddressList(msg.CcAddr),
|
||||
MessageId: msg.MessageID,
|
||||
}
|
||||
|
||||
return &backend.MessageUpdate{
|
||||
Update: backend.NewUpdate(userEmail, mailbox),
|
||||
Message: imapMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFlagsUpdate 为一条消息的标志变化构造 IMAP 更新(已读/星标/删除标记)。
|
||||
// deleted 为会话内 \Deleted 标记(IMAP STORE 会话状态,不入库)。
|
||||
func buildFlagsUpdate(stores *store.Stores, userEmail, mailbox string, msg *db.Message, deleted bool) *backend.MessageUpdate {
|
||||
if stores == nil || msg == nil || userEmail == "" || mailbox == "" {
|
||||
return nil
|
||||
}
|
||||
imapMsg := imap.NewMessage(seqOf(stores, msg.UserID, mailbox, msg.ID),
|
||||
[]imap.FetchItem{imap.FetchUid, imap.FetchFlags})
|
||||
imapMsg.Uid = uint32(msg.ID)
|
||||
imapMsg.Flags = flagsOf(msg.IsRead, msg.IsFlagged, deleted)
|
||||
return &backend.MessageUpdate{
|
||||
Update: backend.NewUpdate(userEmail, mailbox),
|
||||
Message: imapMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// seqOf 返回消息在文件夹中的序号(1 基),未找到返回 0。
|
||||
func seqOf(stores *store.Stores, userID uint, mailbox string, msgID uint) uint32 {
|
||||
msgs, err := stores.Mails.ListAllByUserAndFolder(userID, mailbox)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for i := range msgs {
|
||||
if msgs[i].ID == msgID {
|
||||
return uint32(i + 1)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// flagsOf 按数据库状态生成 IMAP 标志列表(deleted 为会话内 \Deleted 标记)。
|
||||
func flagsOf(read, flagged, deleted bool) []string {
|
||||
flags := make([]string, 0, 3)
|
||||
if read {
|
||||
flags = append(flags, "\\Seen")
|
||||
}
|
||||
if flagged {
|
||||
flags = append(flags, "\\Flagged")
|
||||
}
|
||||
if deleted {
|
||||
flags = append(flags, "\\Deleted")
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// pushUpdate 非阻塞地把一条后端更新送入推送通道(满则丢弃并记日志)。
|
||||
func pushUpdate(ch chan backend.Update, u backend.Update) {
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- u:
|
||||
default:
|
||||
log.Printf("IMAP: 推送通道已满,丢弃更新")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
user, err := b.stores.Users.Authenticate(username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries)
|
||||
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin)
|
||||
// 认证失败计数,达到阈值按档位封禁(与 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 +159,68 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
|
||||
email = user.Username + "@" + domain.Name
|
||||
}
|
||||
|
||||
logID := b.recordLogin(clientIP, username, true, "", "LOGIN 成功", 0, now)
|
||||
|
||||
// 连接追踪:注册到当前连接中心,Logout 时注销;
|
||||
// 注册强制断开回调(按远端地址匹配,断开时由 go-imap 走正常收尾)。
|
||||
conn := b.hub.Register("imap", clientIP, b.port, connInfo.TLS != nil)
|
||||
if conn != nil {
|
||||
conn.SetUser(email)
|
||||
remoteAddr := ""
|
||||
if connInfo.RemoteAddr != nil {
|
||||
remoteAddr = connInfo.RemoteAddr.String()
|
||||
}
|
||||
if b.disconnectAddr != nil && remoteAddr != "" {
|
||||
addr := remoteAddr
|
||||
conn.SetDisconnect(func() { b.disconnectAddr(addr) })
|
||||
}
|
||||
}
|
||||
|
||||
return &imapUser{
|
||||
stores: b.stores,
|
||||
id: user.ID,
|
||||
email: email,
|
||||
stores: b.stores,
|
||||
id: user.ID,
|
||||
email: email,
|
||||
logID: logID,
|
||||
clientIP: clientIP,
|
||||
startedAt: now,
|
||||
conn: conn,
|
||||
updates: b.updates,
|
||||
}, 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
|
||||
conn *connhub.Conn
|
||||
// updates 所在 backend 的推送通道(STORE/EXPUNGE 等实时同步用)。
|
||||
updates chan backend.Update
|
||||
}
|
||||
|
||||
// Username returns the user's email address.
|
||||
@@ -146,6 +301,15 @@ 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)
|
||||
}
|
||||
u.conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -297,6 +461,15 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [
|
||||
if err == nil {
|
||||
imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
|
||||
}
|
||||
// 防御:FetchBodyStructure 对部分合法/畸形 MIME 会失败并返回
|
||||
// nil(典型:message/rfc822 附件为 base64 编码时库内不解码
|
||||
// 直接按嵌套消息解析头;或 multipart 边界截断)。BodyStructure
|
||||
// 为 nil 时 go-imap 格式化 FETCH 响应会在 send() 协程 panic
|
||||
// (nil 指针解引用),连接中断导致客户端只收到部分邮件甚至
|
||||
// 一直卡在同步。解析失败时降级为 text/plain 单段结构。
|
||||
if imapMsg.BodyStructure == nil {
|
||||
imapMsg.BodyStructure = fallbackBodyStructure(rawMsg)
|
||||
}
|
||||
default:
|
||||
section, err := imap.ParseBodySectionName(item)
|
||||
if err != nil {
|
||||
@@ -307,13 +480,33 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [
|
||||
return nil, err
|
||||
}
|
||||
literal, _ := backendutil.FetchBodySection(hdr, body, section)
|
||||
imapMsg.Body[section] = literal
|
||||
if literal != nil {
|
||||
imapMsg.Body[section] = literal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imapMsg, nil
|
||||
}
|
||||
|
||||
// fallbackBodyStructure 构造一个 text/plain 单段 BodyStructure,用于
|
||||
// MIME 解析失败的消息(保证 FETCH BODY/BODYSTRUCTURE 不因 nil 崩溃)。
|
||||
func fallbackBodyStructure(raw []byte) *imap.BodyStructure {
|
||||
size := uint32(len(raw))
|
||||
lines := uint32(bytes.Count(raw, []byte{'\n'}))
|
||||
if len(raw) > 0 && raw[len(raw)-1] != '\n' {
|
||||
lines++
|
||||
}
|
||||
return &imap.BodyStructure{
|
||||
MIMEType: "text",
|
||||
MIMESubType: "plain",
|
||||
Params: map[string]string{"charset": "utf-8"},
|
||||
Encoding: "8bit",
|
||||
Size: size,
|
||||
Lines: lines,
|
||||
}
|
||||
}
|
||||
|
||||
func messageRawData(msg *db.Message) []byte {
|
||||
if msg.RawData != "" {
|
||||
return []byte(msg.RawData)
|
||||
@@ -570,6 +763,9 @@ func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Li
|
||||
return fmt.Errorf("failed to create message: %w", err)
|
||||
}
|
||||
|
||||
// 新邮件(IMAP APPEND)→ 推送给同用户其他已选中该邮箱的客户端
|
||||
pushUpdate(m.user.updates, buildNewMessageUpdate(m.stores, m.user.email, m.name, msg))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -584,6 +780,10 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录首个持久化错误:SQLite 忙/锁等瞬时失败必须让客户端感知
|
||||
// (返回 NO 触发重试),否则已读/星标会静默丢失。
|
||||
var firstErr error
|
||||
|
||||
for i, dbMsg := range dbMessages {
|
||||
var match bool
|
||||
if uid {
|
||||
@@ -603,9 +803,15 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
|
||||
applyFlag := func(flag string, enabled bool) {
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
_ = m.stores.Mails.MarkReadState(dbMsg.ID, enabled)
|
||||
if err := m.stores.Mails.MarkReadState(dbMsg.ID, enabled); err != nil && firstErr == nil {
|
||||
log.Printf("IMAP: mark read state for msg %d failed: %v", dbMsg.ID, err)
|
||||
firstErr = err
|
||||
}
|
||||
case "\\Flagged":
|
||||
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, enabled)
|
||||
if err := m.stores.Mails.MarkFlagged(dbMsg.ID, enabled); err != nil && firstErr == nil {
|
||||
log.Printf("IMAP: mark flagged for msg %d failed: %v", dbMsg.ID, err)
|
||||
firstErr = err
|
||||
}
|
||||
case "\\Deleted":
|
||||
if enabled {
|
||||
m.deleted[dbMsg.ID] = true
|
||||
@@ -629,9 +835,18 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
|
||||
applyFlag(flag, false)
|
||||
}
|
||||
}
|
||||
|
||||
// 标志变化(已读/星标/删除)→ 推送给同用户其他客户端
|
||||
// (重新读库取最新状态,\Deleted 取会话内状态)
|
||||
fresh, err := m.stores.Mails.GetByID(dbMsg.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
deleted := m.deleted != nil && m.deleted[dbMsg.ID]
|
||||
pushUpdate(m.user.updates, buildFlagsUpdate(m.stores, m.user.email, m.name, fresh, deleted))
|
||||
}
|
||||
|
||||
return nil
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// CopyMessages copies messages to another mailbox.
|
||||
@@ -675,7 +890,10 @@ func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) e
|
||||
}
|
||||
if err := m.stores.Mails.Create(copyMsg); err != nil {
|
||||
log.Printf("IMAP: failed to copy message %d to %s: %v", dbMsg.ID, dest, err)
|
||||
continue
|
||||
}
|
||||
// 目标邮箱新增 → 推送给同用户其他客户端
|
||||
pushUpdate(m.user.updates, buildNewMessageUpdate(m.stores, m.user.email, dest, copyMsg))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -705,6 +923,11 @@ func (m *imapMailbox) MoveMessages(uid bool, seqset *imap.SeqSet, dest string) e
|
||||
}
|
||||
if err := m.stores.Mails.MoveToFolder(dbMsg.ID, dest); err != nil {
|
||||
log.Printf("IMAP: failed to move message %d to %s: %v", dbMsg.ID, dest, err)
|
||||
continue
|
||||
}
|
||||
// 目标邮箱新增(移动后消息在 dest)→ 推送给同用户其他客户端
|
||||
if moved, err := m.stores.Mails.GetByID(dbMsg.ID); err == nil {
|
||||
pushUpdate(m.user.updates, buildNewMessageUpdate(m.stores, m.user.email, dest, moved))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -716,12 +939,28 @@ func (m *imapMailbox) Expunge() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 删除前计算各消息的序号(Expunge 响应序号为删除前状态下的序号)
|
||||
var seqs []uint32
|
||||
for msgID := range m.deleted {
|
||||
if seq := seqOf(m.stores, m.user.id, m.name, msgID); seq > 0 {
|
||||
seqs = append(seqs, seq)
|
||||
}
|
||||
}
|
||||
|
||||
for msgID := range m.deleted {
|
||||
if err := m.stores.Mails.Delete(msgID); err != nil {
|
||||
log.Printf("IMAP: failed to expunge message %d: %v", msgID, err)
|
||||
}
|
||||
}
|
||||
m.deleted = make(map[uint]bool)
|
||||
|
||||
// 删除 → 推送给同用户其他客户端(每条序号一个 ExpungeUpdate)
|
||||
for _, seq := range seqs {
|
||||
pushUpdate(m.user.updates, &backend.ExpungeUpdate{
|
||||
Update: backend.NewUpdate(m.user.email, m.name),
|
||||
SeqNum: seq,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//go:build !race
|
||||
|
||||
// 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap client)。
|
||||
// 注意:仅在非 -race 构建下运行——go-imap v1.2.1 存在库内数据竞争
|
||||
// (cmd_selected.go STORE 写 *conn.silent() vs listenUpdates 读),
|
||||
// 启用 backend 推送(Updates != nil)时必然触发,-race 下会误报。
|
||||
// 推送逻辑的竞态覆盖由单元测试(notify_test.go)承担。
|
||||
package imap_server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// startIntegrationServer 启动一个真实的 IMAP 监听(随机端口)供客户端测试。
|
||||
func startIntegrationServer(t *testing.T) (*store.Stores, string) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := stores.Domains.Create(domain); err != nil {
|
||||
t.Fatalf("create domain: %v", err)
|
||||
}
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
|
||||
user := &db.User{Username: "alice", DomainID: domain.ID, PasswordHash: string(hashed), IsActive: true}
|
||||
if err := stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
imapSrv := srv.newServer(ln.Addr().String(), nil)
|
||||
go imapSrv.Serve(ln)
|
||||
|
||||
return stores, ln.Addr().String()
|
||||
}
|
||||
|
||||
// seedMailbox 创建 n 封按时间递增的邮件(id 与 date 顺序一致时 id ASC == date ASC)。
|
||||
func seedMailbox(t *testing.T, stores *store.Stores, userID uint, n int) []uint {
|
||||
t.Helper()
|
||||
ids := make([]uint, 0, n)
|
||||
base := time.Now().Add(-time.Duration(n) * time.Hour)
|
||||
for i := 0; i < n; i++ {
|
||||
msg := &db.Message{
|
||||
UserID: userID,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "x@y",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "m",
|
||||
Date: base.Add(time.Duration(i) * time.Hour), // 时间递增:id 越大日期越新
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := stores.Mails.Create(msg); err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
ids = append(ids, msg.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func loginAndSelect(t *testing.T, addr string) *client.Client {
|
||||
t.Helper()
|
||||
c, err := client.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Logout() })
|
||||
if err := c.Login("alice@example.com", "secret123"); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if _, err := c.Select("INBOX", false); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// uidOf returns the message id of the newest message by date.
|
||||
func assertReadState(t *testing.T, stores *store.Stores, msgID uint, want bool) {
|
||||
t.Helper()
|
||||
msg, err := stores.Mails.GetByID(msgID)
|
||||
if err != nil {
|
||||
t.Fatalf("get msg: %v", err)
|
||||
}
|
||||
if msg.IsRead != want {
|
||||
t.Fatalf("msg %d IsRead = %v, want %v", msgID, msg.IsRead, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUidStorePersists 验证 UID STORE +FLAGS(\Seen) 持久化(RFC 标准流程)。
|
||||
func TestUidStorePersists(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 3)
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(uint32(ids[1])) // UID = 第二条消息
|
||||
ch := make(chan *imap.Message, 1)
|
||||
if err := c.UidStore(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil {
|
||||
t.Fatalf("uid store: %v", err)
|
||||
}
|
||||
<-ch
|
||||
|
||||
assertReadState(t, stores, ids[1], true)
|
||||
assertReadState(t, stores, ids[0], false)
|
||||
assertReadState(t, stores, ids[2], false)
|
||||
}
|
||||
|
||||
// TestSeqStoreServerIssued 验证客户端用服务器下发的序号(FETCH 结果)做
|
||||
// seq 式 STORE:任何排序下都应正确持久化。
|
||||
func TestSeqStoreServerIssued(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 3)
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
// 拉取全部消息,找到 ids[2](最新一封)的服务器序号
|
||||
seqsetAll := new(imap.SeqSet)
|
||||
seqsetAll.AddRange(1, 3)
|
||||
messages := make(chan *imap.Message, 3)
|
||||
if err := c.Fetch(seqsetAll, []imap.FetchItem{imap.FetchFlags, imap.FetchUid}, messages); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
var targetSeq uint32
|
||||
for m := range messages {
|
||||
if m.Uid == uint32(ids[2]) {
|
||||
targetSeq = m.SeqNum
|
||||
}
|
||||
}
|
||||
if targetSeq == 0 {
|
||||
t.Fatal("target message not found in fetch")
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(targetSeq)
|
||||
ch := make(chan *imap.Message, 1)
|
||||
if err := c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
<-ch
|
||||
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
}
|
||||
|
||||
// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号,
|
||||
// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。
|
||||
// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC),
|
||||
// 否则会把另一封邮件标为已读、目标邮件永远未读。
|
||||
func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2]
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
// 客户端按日期倒序视图:最新一封 = seq 1
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(1)
|
||||
ch := make(chan *imap.Message, 1)
|
||||
if err := c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
<-ch
|
||||
|
||||
// 客户端意图是标记最新一封(ids[2])为已读
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
}
|
||||
|
||||
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
||||
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
|
||||
// 不得因 nil BodyStructure 触发服务器 panic(否则连接中断,客户端只收到
|
||||
// 部分邮件或一直卡在同步)。修复前 go-imap send() 协程会 nil 指针崩溃。
|
||||
func TestFetchBodyMalformedMIME(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
|
||||
// 1) base64 编码的 message/rfc822 附件(转发邮件场景):
|
||||
// backendutil.FetchBodyStructure 不解码 base64,直接把编码文本
|
||||
// 当嵌套消息头解析 → "malformed MIME header line" 错误。
|
||||
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
|
||||
msgWithRFC822 := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "alice@example.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "fwd",
|
||||
Date: time.Now().Add(-2 * time.Hour),
|
||||
RawData: "From: alice@example.com\r\n" +
|
||||
"To: alice@example.com\r\n" +
|
||||
"Subject: fwd\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=\"==fwd==\"\r\n\r\n" +
|
||||
"--==fwd==\r\n" +
|
||||
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
|
||||
"正文\r\n\r\n" +
|
||||
"--==fwd==\r\n" +
|
||||
"Content-Type: message/rfc822\r\n" +
|
||||
"Content-Transfer-Encoding: base64\r\n" +
|
||||
"Content-Disposition: attachment; filename=\"original.eml\"\r\n" +
|
||||
"MIME-Version: 1.0\r\n\r\n" +
|
||||
rfc822Body + "\r\n" +
|
||||
"--==fwd==--\r\n",
|
||||
}
|
||||
// 2) 截断的 multipart(缺少结束边界):BODYSTRUCTURE(extended) 解析报错
|
||||
msgTruncated := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "alice@example.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "truncated",
|
||||
Date: time.Now().Add(-1 * time.Hour),
|
||||
RawData: "From: alice@example.com\r\n" +
|
||||
"To: alice@example.com\r\n" +
|
||||
"Subject: truncated\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: multipart/alternative; boundary=\"==trunc==\"\r\n\r\n" +
|
||||
"--==trunc==\r\n" +
|
||||
"Content-Type: text/plain\r\n\r\n" +
|
||||
"hello\r\n",
|
||||
// 无结束边界
|
||||
}
|
||||
if err := stores.Mails.Create(msgWithRFC822); err != nil {
|
||||
t.Fatalf("create msg: %v", err)
|
||||
}
|
||||
if err := stores.Mails.Create(msgTruncated); err != nil {
|
||||
t.Fatalf("create msg: %v", err)
|
||||
}
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddRange(1, 2)
|
||||
|
||||
// BODY:历史上 message/rfc822 消息解析失败 → nil BodyStructure → panic
|
||||
msgs := make(chan *imap.Message, 10)
|
||||
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBody}, msgs); err != nil {
|
||||
t.Fatalf("fetch body: %v", err)
|
||||
}
|
||||
got := 0
|
||||
for range msgs {
|
||||
got++
|
||||
}
|
||||
if got != 2 {
|
||||
t.Fatalf("FETCH BODY 返回 %d/2 封", got)
|
||||
}
|
||||
|
||||
// BODYSTRUCTURE:截断 multipart 在 extended 解析时报错 → nil → panic
|
||||
msgs2 := make(chan *imap.Message, 10)
|
||||
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBodyStructure}, msgs2); err != nil {
|
||||
t.Fatalf("fetch bodystructure: %v", err)
|
||||
}
|
||||
got2 := 0
|
||||
for range msgs2 {
|
||||
got2++
|
||||
}
|
||||
if got2 != 2 {
|
||||
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", got2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package imap_server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestPushNewMessage 验证本地投递成功后推送的 MessageUpdate 内容正确。
|
||||
func TestPushNewMessage(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := stores.Domains.Create(domain); err != nil {
|
||||
t.Fatalf("create domain: %v", err)
|
||||
}
|
||||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
|
||||
if err := stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
email := "alice@example.com"
|
||||
|
||||
// 已有一封旧邮件(日期更早);规范排序最新在前,新邮件应为 INBOX 第 1 封
|
||||
old := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now().Add(-time.Hour)}
|
||||
if err := stores.Mails.Create(old); err != nil {
|
||||
t.Fatalf("create old message: %v", err)
|
||||
}
|
||||
inboxMsg := &db.Message{
|
||||
UserID: user.ID,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "sender@other.com",
|
||||
ToAddr: email,
|
||||
Subject: "新邮件",
|
||||
RawData: "From: sender@other.com\r\nSubject: 新邮件\r\n\r\nhello",
|
||||
MessageID: "<new-1@other.com>",
|
||||
Date: time.Now(),
|
||||
IsRead: false,
|
||||
}
|
||||
if err := stores.Mails.Create(inboxMsg); err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
// 模拟明文 + TLS 两个监听器(生产环境由 Start/StartTLS 注册)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.newServer("127.0.0.1:993", nil)
|
||||
srv.PushNewMessage(email, inboxMsg)
|
||||
|
||||
// 两个监听器(明文/TLS)各有一个 backend 通道,都应收到同一更新
|
||||
srv.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), srv.bes...)
|
||||
srv.beMu.Unlock()
|
||||
if len(bes) == 0 {
|
||||
t.Fatal("no backends registered")
|
||||
}
|
||||
|
||||
for i, b := range bes {
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
mu, ok := upd.(*backend.MessageUpdate)
|
||||
if !ok {
|
||||
t.Fatalf("backend %d: update type = %T, want *MessageUpdate", i, upd)
|
||||
}
|
||||
if mu.Username() != email {
|
||||
t.Fatalf("backend %d: username = %q, want %q", i, mu.Username(), email)
|
||||
}
|
||||
if mu.Mailbox() != "INBOX" {
|
||||
t.Fatalf("backend %d: mailbox = %q, want INBOX", i, mu.Mailbox())
|
||||
}
|
||||
if mu.Message.Uid != uint32(inboxMsg.ID) {
|
||||
t.Fatalf("backend %d: uid = %d, want %d", i, mu.Message.Uid, inboxMsg.ID)
|
||||
}
|
||||
if mu.Message.SeqNum != 1 {
|
||||
t.Fatalf("backend %d: seq = %d, want 1", i, mu.Message.SeqNum)
|
||||
}
|
||||
if mu.Message.Envelope == nil || mu.Message.Envelope.Subject != "新邮件" {
|
||||
t.Fatalf("backend %d: envelope missing subject", i)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("backend %d: no update received", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushNewMessageChannelFull 验证通道满时推送不阻塞(非阻塞丢弃)。
|
||||
func TestPushNewMessageChannelFull(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.newServer("127.0.0.1:993", nil)
|
||||
|
||||
msg := &db.Message{ID: 1, Folder: "INBOX", Date: time.Now()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// 灌满所有 backend 通道(容量 256),再调用必须立即返回
|
||||
srv.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), srv.bes...)
|
||||
srv.beMu.Unlock()
|
||||
for _, b := range bes {
|
||||
for i := 0; i < cap(b.updates); i++ {
|
||||
b.updates <- backend.NewUpdate("a@b", "INBOX")
|
||||
}
|
||||
}
|
||||
srv.PushNewMessage("a@b", msg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("PushNewMessage blocked on full channel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushNewMessageNilSafe 验证空参数/空指针安全。
|
||||
func TestPushNewMessageNilSafe(t *testing.T) {
|
||||
var srv *IMAPServer
|
||||
srv.PushNewMessage("a@b", &db.Message{ID: 1}) // 不应 panic
|
||||
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil)
|
||||
srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱
|
||||
srv.PushNewMessage("a@b", nil) // 空消息
|
||||
}
|
||||
|
||||
// TestPushFlagsChanged 验证标志变化(已读/星标)推送内容正确。
|
||||
func TestPushFlagsChanged(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := stores.Domains.Create(domain); err != nil {
|
||||
t.Fatalf("create domain: %v", err)
|
||||
}
|
||||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
|
||||
if err := stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
|
||||
if err := stores.Mails.Create(msg); err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
msg.IsRead = true
|
||||
msg.IsFlagged = true
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.PushFlagsChanged("alice@example.com", "INBOX", msg)
|
||||
|
||||
srv.beMu.Lock()
|
||||
b := srv.bes[0]
|
||||
srv.beMu.Unlock()
|
||||
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
mu, ok := upd.(*backend.MessageUpdate)
|
||||
if !ok {
|
||||
t.Fatalf("update type = %T, want *MessageUpdate", upd)
|
||||
}
|
||||
if mu.Username() != "alice@example.com" || mu.Mailbox() != "INBOX" {
|
||||
t.Fatalf("update targeting = %s/%s", mu.Username(), mu.Mailbox())
|
||||
}
|
||||
if mu.Message.Uid != uint32(msg.ID) {
|
||||
t.Fatalf("uid = %d, want %d", mu.Message.Uid, msg.ID)
|
||||
}
|
||||
got := make(map[string]bool)
|
||||
for _, f := range mu.Message.Flags {
|
||||
got[f] = true
|
||||
}
|
||||
if !got["\\Seen"] || !got["\\Flagged"] {
|
||||
t.Fatalf("flags = %v, want \\Seen and \\Flagged", mu.Message.Flags)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no flags update received")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushExpunged 验证删除推送:每条序号一个 ExpungeUpdate。
|
||||
func TestPushExpunged(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.PushExpunged("alice@example.com", "INBOX", []uint32{2, 5})
|
||||
|
||||
srv.beMu.Lock()
|
||||
b := srv.bes[0]
|
||||
srv.beMu.Unlock()
|
||||
|
||||
var seqs []uint32
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
eu, ok := upd.(*backend.ExpungeUpdate)
|
||||
if !ok {
|
||||
t.Fatalf("update type = %T, want *ExpungeUpdate", upd)
|
||||
}
|
||||
if eu.Username() != "alice@example.com" || eu.Mailbox() != "INBOX" {
|
||||
t.Fatalf("update targeting = %s/%s", eu.Username(), eu.Mailbox())
|
||||
}
|
||||
seqs = append(seqs, eu.SeqNum)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no expunge update received")
|
||||
}
|
||||
}
|
||||
if seqs[0] != 2 || seqs[1] != 5 {
|
||||
t.Fatalf("seqs = %v, want [2 5]", seqs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastUpdateIsolatedPerListener 回归测试:同一更新广播到多个监听器
|
||||
// 时,每个监听器必须持有独立的 Update 对象(独立 Done channel),否则
|
||||
// 多个 listenUpdates 会对同一 channel 二次 close 导致
|
||||
// panic: close of closed channel。
|
||||
func TestBroadcastUpdateIsolatedPerListener(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.newServer("127.0.0.1:993", nil)
|
||||
|
||||
srv.PushExpunged("alice@example.com", "INBOX", []uint32{1})
|
||||
|
||||
srv.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), srv.bes...)
|
||||
srv.beMu.Unlock()
|
||||
|
||||
// 每个监听器各收到一条更新
|
||||
var updates []backend.Update
|
||||
for i, b := range bes {
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
updates = append(updates, upd)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("backend %d: no update received", i)
|
||||
}
|
||||
}
|
||||
if len(updates) != 2 {
|
||||
t.Fatalf("updates = %d, want 2", len(updates))
|
||||
}
|
||||
|
||||
// 关键断言:两条更新必须拥有独立的 Done channel
|
||||
if updates[0].Done() == updates[1].Done() {
|
||||
t.Fatal("listeners share the same Done channel: double close would panic")
|
||||
}
|
||||
|
||||
// 模拟两个 listenUpdates 各自执行 close(update.Done()):修复前必 panic
|
||||
for _, upd := range updates {
|
||||
close(upd.Done())
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,13 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/tlsutil"
|
||||
|
||||
@@ -13,22 +18,151 @@ import (
|
||||
imapserver "github.com/emersion/go-imap/server"
|
||||
)
|
||||
|
||||
// Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用,
|
||||
// 由 go-imap 广播给相关客户端(按用户名+邮箱过滤,IDLE 时即时送达)。
|
||||
type Pusher interface {
|
||||
// PushNewMessage 推送新邮件(本地投递成功)。
|
||||
PushNewMessage(userEmail string, msg *db.Message)
|
||||
// PushFlagsChanged 推送已读/星标等标志变化(MessageUpdate)。
|
||||
PushFlagsChanged(userEmail, mailbox string, msg *db.Message)
|
||||
// PushExpunged 推送邮件被删除(ExpungeUpdate,seqNums 为删除前序号)。
|
||||
PushExpunged(userEmail, mailbox string, seqNums []uint32)
|
||||
}
|
||||
|
||||
// IMAPServer wraps a go-imap Server and provides mailbox access capability.
|
||||
type IMAPServer struct {
|
||||
stores *store.Stores
|
||||
cfg config.IMAPConfig
|
||||
banCfg config.BanConfig
|
||||
tlsLoader *tlsutil.Loader
|
||||
hub *connhub.Hub
|
||||
|
||||
beMu sync.Mutex
|
||||
bes []*imapBackend // 各监听器(明文/TLS)的 backend,用于新邮件推送
|
||||
srvs []*imapserver.Server // 各监听器实例,用于强制断开连接
|
||||
}
|
||||
|
||||
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
|
||||
// when TLS is not configured.
|
||||
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *IMAPServer {
|
||||
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *IMAPServer {
|
||||
return &IMAPServer{
|
||||
stores: stores,
|
||||
cfg: cfg,
|
||||
banCfg: banCfg,
|
||||
tlsLoader: tlsLoader,
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyNewMessage 向所有 IMAP 监听器推送新邮件通知(go-imap 广播时按
|
||||
// 用户名+邮箱过滤,只送达已选中 INBOX 的客户端,IDLE 挂起时实时收到
|
||||
// FETCH 响应)。由 SMTP/Web 本地投递成功时调用;channel 满时非阻塞丢弃。
|
||||
func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
|
||||
if s == nil || userEmail == "" || msg == nil {
|
||||
return
|
||||
}
|
||||
update := buildNewMessageUpdate(s.stores, userEmail, "INBOX", msg)
|
||||
if update == nil {
|
||||
return
|
||||
}
|
||||
s.broadcastUpdate(update, userEmail, msg.ID)
|
||||
}
|
||||
|
||||
// PushFlagsChanged 推送邮件标志(已读/星标等)变化给同用户的其他客户端。
|
||||
func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) {
|
||||
if s == nil || userEmail == "" || mailbox == "" || msg == nil {
|
||||
return
|
||||
}
|
||||
update := buildFlagsUpdate(s.stores, userEmail, mailbox, msg, false)
|
||||
if update == nil {
|
||||
return
|
||||
}
|
||||
s.broadcastUpdate(update, userEmail, msg.ID)
|
||||
}
|
||||
|
||||
// PushExpunged 推送邮件被删除(每条序号一个 ExpungeUpdate)。
|
||||
func (s *IMAPServer) PushExpunged(userEmail, mailbox string, seqNums []uint32) {
|
||||
if s == nil || userEmail == "" || mailbox == "" || len(seqNums) == 0 {
|
||||
return
|
||||
}
|
||||
for _, seq := range seqNums {
|
||||
update := &backend.ExpungeUpdate{
|
||||
Update: backend.NewUpdate(userEmail, mailbox),
|
||||
SeqNum: seq,
|
||||
}
|
||||
s.broadcastUpdate(update, userEmail, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastUpdate 把一条更新非阻塞地投递到所有监听器的推送通道。
|
||||
// 每个监听器必须收到独立的 Update 对象(各自独立的 Done channel):
|
||||
// 每个监听器的 listenUpdates 都会对 update.Done() 执行 close,共享
|
||||
// 同一对象会导致对同一 channel 二次 close 而 panic。
|
||||
func (s *IMAPServer) broadcastUpdate(update backend.Update, userEmail string, msgID uint) {
|
||||
s.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), s.bes...)
|
||||
s.beMu.Unlock()
|
||||
|
||||
for _, b := range bes {
|
||||
select {
|
||||
case b.updates <- cloneUpdate(update):
|
||||
default:
|
||||
log.Printf("IMAP: 推送通道已满,丢弃 %s 的更新 (msg=%d)", userEmail, msgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享,
|
||||
// 但 Username/Mailbox/Done channel 重置为独立实例。
|
||||
func cloneUpdate(u backend.Update) backend.Update {
|
||||
switch u := u.(type) {
|
||||
case *backend.MessageUpdate:
|
||||
return &backend.MessageUpdate{
|
||||
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
|
||||
Message: u.Message,
|
||||
}
|
||||
case *backend.ExpungeUpdate:
|
||||
return &backend.ExpungeUpdate{
|
||||
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
|
||||
SeqNum: u.SeqNum,
|
||||
}
|
||||
default:
|
||||
// 防御:未知类型原样传递(当前不存在此类更新)
|
||||
return u
|
||||
}
|
||||
}
|
||||
|
||||
// registerBackend 记录新建的 backend(用于新邮件推送)。
|
||||
func (s *IMAPServer) registerBackend(be *imapBackend) {
|
||||
s.beMu.Lock()
|
||||
s.bes = append(s.bes, be)
|
||||
s.beMu.Unlock()
|
||||
}
|
||||
|
||||
// registerServer 记录监听器实例(用于强制断开连接)。
|
||||
func (s *IMAPServer) registerServer(srv *imapserver.Server) {
|
||||
s.beMu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.beMu.Unlock()
|
||||
}
|
||||
|
||||
// DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。
|
||||
// 关闭连接会触发 go-imap 的收尾流程(user.Logout、协议日志回填、hub 注销)。
|
||||
func (s *IMAPServer) DisconnectByAddr(remoteAddr string) {
|
||||
if s == nil || remoteAddr == "" {
|
||||
return
|
||||
}
|
||||
s.beMu.Lock()
|
||||
srvs := append([]*imapserver.Server(nil), s.srvs...)
|
||||
s.beMu.Unlock()
|
||||
|
||||
for _, srv := range srvs {
|
||||
srv.ForEachConn(func(conn imapserver.Conn) {
|
||||
info := conn.Info()
|
||||
if info != nil && info.RemoteAddr != nil && info.RemoteAddr.String() == remoteAddr {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +176,36 @@ 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),
|
||||
hub: s.hub,
|
||||
updates: make(chan backend.Update, 256),
|
||||
disconnectAddr: s.DisconnectByAddr,
|
||||
}
|
||||
s.registerBackend(be)
|
||||
srv := imapserver.New(be)
|
||||
srv.Addr = addr
|
||||
srv.TLSConfig = tlsConfig
|
||||
srv.AllowInsecureAuth = tlsConfig == nil
|
||||
s.registerServer(srv)
|
||||
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()
|
||||
|
||||
+108
-18
@@ -16,8 +16,13 @@ import (
|
||||
)
|
||||
|
||||
// Manager orchestrates the outbound delivery queue: enqueueing messages,
|
||||
// background delivery worker, exponential backoff retries, DKIM signing,
|
||||
// per-user rate limits and failure bounces.
|
||||
// concurrent background delivery workers, exponential backoff retries, DKIM
|
||||
// signing, per-user rate limits and failure bounces.
|
||||
//
|
||||
// 并发模型:一个 dispatcher goroutine 周期性扫描队列并原子抢占(Claim)
|
||||
// 待投递项,投递给 worker 池(默认 4 个 goroutine)并行发送;同一收件域
|
||||
// (或中继)的连接数受 max_concurrent_per_domain 限制。workers=0/1 时退
|
||||
// 化为串行投递(旧行为)。
|
||||
type Manager struct {
|
||||
cfg config.OutboundConfig
|
||||
hostname string // EHLO hostname
|
||||
@@ -31,7 +36,12 @@ type Manager struct {
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
lim map[uint]*userWindow
|
||||
batch int
|
||||
jobs chan *db.OutboundMessage
|
||||
domMu sync.Mutex
|
||||
dom map[string]chan struct{} // 每域并发信号量
|
||||
|
||||
// deliver 执行单封投递,默认走 m.mailer.Deliver;测试可注入替换。
|
||||
deliver func(from, to string, data []byte) (string, error)
|
||||
}
|
||||
|
||||
// userWindow tracks a user's sending rate within fixed windows.
|
||||
@@ -45,6 +55,14 @@ type userWindow struct {
|
||||
// NewManager creates an outbound delivery Manager.
|
||||
// hostname is the EHLO name presented to remote servers (defaults to "localhost").
|
||||
func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores) *Manager {
|
||||
batchSize := cfg.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 50
|
||||
}
|
||||
workers := cfg.Workers
|
||||
if workers <= 1 {
|
||||
workers = 1
|
||||
}
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
hostname: hostname,
|
||||
@@ -54,8 +72,10 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
lim: make(map[uint]*userWindow),
|
||||
batch: 50,
|
||||
jobs: make(chan *db.OutboundMessage, batchSize),
|
||||
dom: make(map[string]chan struct{}),
|
||||
}
|
||||
m.deliver = m.mailer.Deliver
|
||||
m.mailer.IPFamily = cfg.IPFamily
|
||||
m.mailer.SourceIP = cfg.SourceIP
|
||||
if cfg.SourceIP != "" {
|
||||
@@ -73,22 +93,27 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
|
||||
}
|
||||
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
|
||||
}
|
||||
log.Printf("outbound: %d delivery workers, batch=%d, per-domain concurrency=%d",
|
||||
workers, batchSize, cfg.MaxConcurrentPerDomain)
|
||||
return m
|
||||
}
|
||||
|
||||
// Start launches the background delivery worker.
|
||||
// Start launches the dispatcher and the delivery worker pool.
|
||||
func (m *Manager) Start() {
|
||||
interval := time.Duration(m.cfg.PollInterval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
|
||||
// 调度者:启动时立即扫描一次(清空积压),之后按周期扫描 +
|
||||
// 原子抢占待投递项,投递给 worker 池。
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("outbound: delivery worker started (interval=%s, max_attempts=%d)", interval, m.cfg.MaxAttempts)
|
||||
log.Printf("outbound: dispatcher started (interval=%s, max_attempts=%d)", interval, m.cfg.MaxAttempts)
|
||||
m.processDue()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
@@ -96,14 +121,30 @@ func (m *Manager) Start() {
|
||||
case <-m.kick:
|
||||
m.processDue()
|
||||
case <-m.stop:
|
||||
close(m.jobs)
|
||||
close(m.done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Worker 池:并行投递。
|
||||
workers := m.cfg.Workers
|
||||
if workers <= 1 {
|
||||
workers = 1
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
for job := range m.jobs {
|
||||
m.deliverOne(job)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully stops the delivery worker.
|
||||
// Stop gracefully stops the dispatcher and workers.
|
||||
func (m *Manager) Stop() {
|
||||
m.once.Do(func() {
|
||||
close(m.stop)
|
||||
@@ -243,28 +284,77 @@ func (m *Manager) checkRateLimit(userID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// processDue attempts delivery of all due queue items.
|
||||
// processDue scans the queue and dispatches due items to the worker pool.
|
||||
// Each item is atomically claimed (status -> sending) before dispatch so
|
||||
// that concurrent workers never deliver the same message twice. When all
|
||||
// workers are busy the dispatcher blocks here, naturally throttling claims;
|
||||
// remaining due items are picked up on the next scan.
|
||||
func (m *Manager) processDue() {
|
||||
items, err := m.stores.Outbound.ListDue(time.Now(), m.batch)
|
||||
batchSize := m.cfg.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 50
|
||||
}
|
||||
items, err := m.stores.Outbound.ListDue(time.Now(), batchSize)
|
||||
if err != nil {
|
||||
log.Printf("outbound: loading due queue failed: %v", err)
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
m.deliverOne(&items[i])
|
||||
claimed, err := m.stores.Outbound.Claim(items[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("outbound: claim item %d failed: %v", items[i].ID, err)
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
// 已被其他调度周期抢占(并发下应不会发生,防御性跳过)
|
||||
continue
|
||||
}
|
||||
item := items[i]
|
||||
item.Status = db.OutboundStatusSending
|
||||
m.jobs <- &item
|
||||
}
|
||||
}
|
||||
|
||||
// deliverOne performs a single delivery attempt for a queue item.
|
||||
func (m *Manager) deliverOne(item *db.OutboundMessage) {
|
||||
// Mark as sending to avoid concurrent workers double-delivering.
|
||||
item.Status = db.OutboundStatusSending
|
||||
if err := m.stores.Outbound.Update(item); err != nil {
|
||||
log.Printf("outbound: update item %d to sending failed: %v", item.ID, err)
|
||||
return
|
||||
// acquireDomain 获取收件域(或中继)的并发信号量,限制对同一目标域同时
|
||||
// 打开的 SMTP 连接数。limit <= 0 表示不限制。
|
||||
func (m *Manager) acquireDomain(domain string) func() {
|
||||
limit := m.cfg.MaxConcurrentPerDomain
|
||||
if limit <= 0 {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
resp, err := m.mailer.Deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
|
||||
m.domMu.Lock()
|
||||
sem := m.dom[domain]
|
||||
if sem == nil {
|
||||
sem = make(chan struct{}, limit)
|
||||
m.dom[domain] = sem
|
||||
}
|
||||
m.domMu.Unlock()
|
||||
|
||||
sem <- struct{}{}
|
||||
return func() { <-sem }
|
||||
}
|
||||
|
||||
// deliverKey 返回并发限制使用的目标标识:配置了中继时所有连接都打向同一
|
||||
// smarthost,统一按 "relay" 限制;否则按收件域名限制。
|
||||
func (m *Manager) deliverKey(to string) string {
|
||||
if m.mailer.Relay != nil && m.mailer.Relay.Host != "" {
|
||||
return "relay"
|
||||
}
|
||||
at := strings.LastIndex(to, "@")
|
||||
if at < 0 || at == len(to)-1 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(to[at+1:])
|
||||
}
|
||||
|
||||
// deliverOne performs a single delivery attempt for a queue item.
|
||||
// 调用前该项已被原子抢占为 sending,此处不再重复置位。
|
||||
func (m *Manager) deliverOne(item *db.OutboundMessage) {
|
||||
release := m.acquireDomain(item.ToAddr)
|
||||
defer release()
|
||||
|
||||
resp, err := m.deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
|
||||
|
||||
now := time.Now()
|
||||
item.Attempts++
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newTestManagerStores 创建带 outbound_messages 表的测试数据库。
|
||||
func newTestManagerStores(t *testing.T) *store.Stores {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.OutboundMessage{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return store.NewStores(gdb)
|
||||
}
|
||||
|
||||
// seedPending 写入 n 条立即可投递的 pending 队列项。
|
||||
func seedPending(t *testing.T, stores *store.Stores, n int) []uint {
|
||||
t.Helper()
|
||||
ids := make([]uint, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
item := &db.OutboundMessage{
|
||||
MessageID: "<seed@test>",
|
||||
FromAddr: "sender@test.local",
|
||||
ToAddr: "rcpt@fake.test",
|
||||
RecipientDom: "fake.test",
|
||||
RawData: "From: sender@test.local\r\nTo: rcpt@fake.test\r\nSubject: t\r\n\r\nbody\r\n",
|
||||
Status: db.OutboundStatusPending,
|
||||
Attempts: 0,
|
||||
NextAttemptAt: time.Now(),
|
||||
}
|
||||
if err := stores.Outbound.Create(item); err != nil {
|
||||
t.Fatalf("create item: %v", err)
|
||||
}
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// countByStatus 统计队列中指定状态的项数。
|
||||
func countByStatus(t *testing.T, stores *store.Stores, status string) int64 {
|
||||
t.Helper()
|
||||
n, err := stores.Outbound.CountByStatus(status)
|
||||
if err != nil {
|
||||
t.Fatalf("count %s: %v", status, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// waitFor 轮询等待条件满足或超时。
|
||||
func waitFor(t *testing.T, timeout time.Duration, desc string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", desc)
|
||||
}
|
||||
|
||||
// concurrencyTracker 统计并发调用数(用于验证 worker 池与每域信号量)。
|
||||
type concurrencyTracker struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
peak int
|
||||
deliveries int
|
||||
err error
|
||||
}
|
||||
|
||||
// deliverWithDelay 返回一个带固定延迟的注入投递函数,并统计并发峰值。
|
||||
func deliverWithDelay(tr *concurrencyTracker, delay time.Duration) func(string, string, []byte) (string, error) {
|
||||
return func(from, to string, data []byte) (string, error) {
|
||||
tr.mu.Lock()
|
||||
tr.active++
|
||||
if tr.active > tr.peak {
|
||||
tr.peak = tr.active
|
||||
}
|
||||
tr.mu.Unlock()
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
tr.mu.Lock()
|
||||
tr.active--
|
||||
tr.deliveries++
|
||||
tr.mu.Unlock()
|
||||
return "250 2.0.0 queued", nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerConcurrentDelivery 验证 worker 池真并发投递且每封只投一次。
|
||||
func TestManagerConcurrentDelivery(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 12)
|
||||
|
||||
tr := &concurrencyTracker{}
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 5,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 4,
|
||||
BatchSize: 50,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = deliverWithDelay(tr, 150*time.Millisecond)
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "all items sent", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusSent) == 12
|
||||
})
|
||||
|
||||
if tr.peak < 2 {
|
||||
t.Fatalf("expected concurrent deliveries (peak=%d), got serial behavior", tr.peak)
|
||||
}
|
||||
if tr.deliveries != 12 {
|
||||
t.Fatalf("deliveries = %d, want 12 (each message exactly once)", tr.deliveries)
|
||||
}
|
||||
if n := countByStatus(t, stores, db.OutboundStatusPending) + countByStatus(t, stores, db.OutboundStatusDeferred); n != 0 {
|
||||
t.Fatalf("%d items still pending/deferred", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerSerialFallback 验证 workers=1 时退化为串行(旧行为)。
|
||||
func TestManagerSerialFallback(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 6)
|
||||
|
||||
tr := &concurrencyTracker{}
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 5,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 1,
|
||||
BatchSize: 50,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = deliverWithDelay(tr, 50*time.Millisecond)
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "all items sent", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusSent) == 6
|
||||
})
|
||||
if tr.peak > 1 {
|
||||
t.Fatalf("workers=1 must be serial, peak=%d", tr.peak)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerDomainLimit 验证同一收件域的并发连接数不超过上限。
|
||||
func TestManagerDomainLimit(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 8)
|
||||
|
||||
tr := &concurrencyTracker{}
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 5,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 8,
|
||||
BatchSize: 50,
|
||||
MaxConcurrentPerDomain: 1,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = deliverWithDelay(tr, 100*time.Millisecond)
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "all items sent", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusSent) == 8
|
||||
})
|
||||
if tr.peak > 1 {
|
||||
t.Fatalf("per-domain limit 1 violated: peak=%d", tr.peak)
|
||||
}
|
||||
if tr.deliveries != 8 {
|
||||
t.Fatalf("deliveries = %d, want 8", tr.deliveries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerRetriesTemporaryFailure 验证临时失败进入退避重试(deferred)。
|
||||
func TestManagerRetriesTemporaryFailure(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 1)
|
||||
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 3,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 2,
|
||||
BatchSize: 50,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = func(from, to string, data []byte) (string, error) {
|
||||
return "", newTempError("connection refused")
|
||||
}
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "item deferred", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusDeferred) == 1
|
||||
})
|
||||
|
||||
items, _, err := stores.Outbound.List(1, 10, db.OutboundStatusDeferred)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("list deferred: %v (n=%d)", err, len(items))
|
||||
}
|
||||
if items[0].Attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", items[0].Attempts)
|
||||
}
|
||||
if items[0].LastError == "" {
|
||||
t.Fatal("expected last error recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerPermanentFailureBouncesAndFails 验证永久失败直接标记 failed。
|
||||
func TestManagerPermanentFailureBouncesAndFails(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 1)
|
||||
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 3,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 2,
|
||||
BatchSize: 50,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = func(from, to string, data []byte) (string, error) {
|
||||
return "", newPermError("550 recipient rejected")
|
||||
}
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "item failed", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusFailed) == 1
|
||||
})
|
||||
if n := countByStatus(t, stores, db.OutboundStatusDeferred); n != 0 {
|
||||
t.Fatalf("permanent failure must not defer: %d deferred", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerDomainLimitNoLimit 验证 max_concurrent_per_domain=0 不限制并发。
|
||||
func TestManagerDomainLimitNoLimit(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 8)
|
||||
|
||||
tr := &concurrencyTracker{}
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 5,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 8,
|
||||
BatchSize: 50,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = deliverWithDelay(tr, 80*time.Millisecond)
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "all items sent", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusSent) == 8
|
||||
})
|
||||
if tr.peak < 2 {
|
||||
t.Fatalf("expected concurrent deliveries without domain limit, peak=%d", tr.peak)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerDeliverErrorsPropagate 防御:投递函数报错不影响 worker 存活,
|
||||
// 错误项进入重试(deferred)。
|
||||
func TestManagerDeliverErrorsPropagate(t *testing.T) {
|
||||
stores := newTestManagerStores(t)
|
||||
seedPending(t, stores, 3)
|
||||
|
||||
var calls atomic.Int32
|
||||
cfg := config.OutboundConfig{
|
||||
PollInterval: 1,
|
||||
MaxAttempts: 2,
|
||||
RetryBaseMin: 1,
|
||||
MaxPerDay: 10000,
|
||||
Workers: 2,
|
||||
BatchSize: 50,
|
||||
ConnectTimeout: 10,
|
||||
}
|
||||
m := NewManager(cfg, "test.local", stores)
|
||||
m.deliver = func(from, to string, data []byte) (string, error) {
|
||||
n := calls.Add(1)
|
||||
if n%2 == 0 {
|
||||
return "", errors.New("boom")
|
||||
}
|
||||
return "250 ok", nil
|
||||
}
|
||||
m.Start()
|
||||
t.Cleanup(m.Stop)
|
||||
|
||||
waitFor(t, 15*time.Second, "queue settled", func() bool {
|
||||
return countByStatus(t, stores, db.OutboundStatusSent)+countByStatus(t, stores, db.OutboundStatusDeferred) == 3
|
||||
})
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("deliver calls = %d, want 3 (once per item)", calls.Load())
|
||||
}
|
||||
if n := countByStatus(t, stores, db.OutboundStatusFailed); n != 0 {
|
||||
t.Fatalf("unexpected failed items: %d", n)
|
||||
}
|
||||
}
|
||||
+142
-16
@@ -12,7 +12,9 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/tlsutil"
|
||||
)
|
||||
@@ -24,13 +26,15 @@ type POP3Server struct {
|
||||
cfg config.POP3Config
|
||||
banCfg config.BanConfig
|
||||
tlsLoader *tlsutil.Loader
|
||||
hub *connhub.Hub
|
||||
pusher imap_server.Pusher // 邮件删除推送(IMAP 客户端同步),可空
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
|
||||
// when TLS is not configured.
|
||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *POP3Server {
|
||||
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
|
||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *POP3Server {
|
||||
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
|
||||
}
|
||||
|
||||
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
|
||||
@@ -48,6 +52,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 +67,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 +86,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 +101,7 @@ func (s *POP3Server) StartTLS() error {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.handleConn(conn)
|
||||
s.handleConn(conn, port)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
@@ -103,31 +109,67 @@ 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
|
||||
}
|
||||
|
||||
// 连接追踪:注册到当前连接中心,连接结束时注销;
|
||||
// 强制断开:关闭底层连接(STLS 后 conn 变量已指向 tlsConn,同样生效)。
|
||||
activeConn := s.hub.Register("pop3", clientIP, port, false)
|
||||
if activeConn != nil {
|
||||
activeConn.SetDisconnect(func() { _ = conn.Close() })
|
||||
}
|
||||
|
||||
// 会话状态(供协议日志汇总)
|
||||
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
|
||||
|
||||
defer func() {
|
||||
if activeConn != nil {
|
||||
activeConn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
sendResponse(conn, "+OK MailGo POP3 server ready")
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -137,12 +179,14 @@ func (s *POP3Server) handleConn(conn net.Conn) {
|
||||
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
commandCount[cmd]++
|
||||
activeConn.Touch()
|
||||
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 +194,18 @@ 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 = ""
|
||||
activeConn.SetUser(authUsername)
|
||||
}
|
||||
case "STAT":
|
||||
s.handleSTAT(conn, messages, deleted)
|
||||
case "LIST":
|
||||
@@ -167,8 +220,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,11 +245,14 @@ 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
|
||||
reader = bufio.NewReader(conn)
|
||||
tlsActive = true
|
||||
activeConn.SetTLS(true)
|
||||
case "TOP":
|
||||
s.handleTOP(conn, arg, messages, deleted)
|
||||
case "UIDL":
|
||||
@@ -202,6 +261,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 +379,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
|
||||
}
|
||||
@@ -285,12 +397,15 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
|
||||
|
||||
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries)
|
||||
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin)
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||||
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
|
||||
sendResponse(conn, "-ERR authentication failed")
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
|
||||
authUser.Username = user.Username
|
||||
|
||||
messages := s.loadMessages(authUser)
|
||||
deleted := make(map[int]bool)
|
||||
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
|
||||
@@ -425,18 +540,29 @@ 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. 删除成功后向 IMAP 客户端推送
|
||||
// Expunge 通知(序号为删除前在 INBOX 中的位置)。
|
||||
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
|
||||
var seqs []uint32
|
||||
for seqNum, msgDeleted := range deleted {
|
||||
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
|
||||
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
|
||||
log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
seqs = append(seqs, uint32(seqNum))
|
||||
}
|
||||
}
|
||||
if s.pusher != nil && len(seqs) > 0 && user.Username != "" {
|
||||
s.pusher.PushExpunged(user.Username, "INBOX", seqs)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// sendResponse writes a POP3 response line to the connection.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// mockPusher 记录推送调用的测试桩。
|
||||
type mockPusher struct {
|
||||
expunged []struct {
|
||||
Email string
|
||||
Mailbox string
|
||||
Seqs []uint32
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPusher) PushNewMessage(string, *db.Message) {}
|
||||
func (m *mockPusher) PushFlagsChanged(string, string, *db.Message) {}
|
||||
func (m *mockPusher) PushExpunged(email, mailbox string, seqs []uint32) {
|
||||
m.expunged = append(m.expunged, struct {
|
||||
Email string
|
||||
Mailbox string
|
||||
Seqs []uint32
|
||||
}{email, mailbox, seqs})
|
||||
}
|
||||
|
||||
// TestExpungePushesIMAPUpdate 验证 POP3 删除邮件后向 IMAP 推送 Expunge。
|
||||
func TestExpungePushesIMAPUpdate(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
pusher := &mockPusher{}
|
||||
s.pusher = pusher
|
||||
|
||||
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, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
|
||||
user.PasswordHash = string(hashed)
|
||||
if err := s.stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "m", Date: time.Now()}
|
||||
if err := s.stores.Mails.Create(msg); err != nil {
|
||||
t.Fatalf("create message %d: %v", i, 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')
|
||||
// 删除第 1 封后退出
|
||||
client.Write([]byte("DELE 1\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")
|
||||
}
|
||||
|
||||
if len(pusher.expunged) != 1 {
|
||||
t.Fatalf("expunge pushes = %d, want 1", len(pusher.expunged))
|
||||
}
|
||||
p := pusher.expunged[0]
|
||||
if p.Email != "alice@example.com" || p.Mailbox != "INBOX" {
|
||||
t.Fatalf("push target = %s/%s", p.Email, p.Mailbox)
|
||||
}
|
||||
if len(p.Seqs) != 1 || p.Seqs[0] != 1 {
|
||||
t.Fatalf("seqs = %v, want [1]", p.Seqs)
|
||||
}
|
||||
// 邮件确实已删除
|
||||
if n, _ := s.stores.Mails.CountByUserAndFolder(user.ID, "INBOX"); n != 2 {
|
||||
t.Fatalf("inbox count = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
@@ -38,12 +42,14 @@ type SMTPServer struct {
|
||||
cfg config.SMTPConfig
|
||||
banCfg config.BanConfig
|
||||
tlsLoader *tlsutil.Loader
|
||||
hub *connhub.Hub
|
||||
pusher imap_server.Pusher // 本地投递成功推送(IMAP 新邮件),可空
|
||||
}
|
||||
|
||||
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
|
||||
// when TLS is not configured.
|
||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *SMTPServer {
|
||||
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
|
||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *SMTPServer {
|
||||
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
|
||||
}
|
||||
|
||||
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
||||
@@ -108,14 +114,50 @@ type smtpBackend struct {
|
||||
|
||||
// NewSession creates a new SMTP session for the incoming connection.
|
||||
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||
clientIP := store.ClientIPFromAddr(c.Conn().RemoteAddr())
|
||||
conn := be.server.hub.Register("smtp", clientIP, be.server.sessionPort(be.mode), be.server.tlsActive(c))
|
||||
if conn != nil {
|
||||
// 强制断开:关闭底层连接后 go-smtp 读到 EOF,正常走 Logout 收尾
|
||||
raw := c.Conn()
|
||||
conn.SetDisconnect(func() { _ = raw.Close() })
|
||||
}
|
||||
return &smtpSession{
|
||||
backend: be,
|
||||
mode: be.mode,
|
||||
rcpts: make([]string, 0),
|
||||
clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()),
|
||||
clientIP: clientIP,
|
||||
startedAt: time.Now(),
|
||||
port: be.server.sessionPort(be.mode),
|
||||
conn: conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tlsActive 判断当前连接是否处于 TLS 加密状态(implicit TLS 或 STARTTLS)。
|
||||
func (s *SMTPServer) tlsActive(c *smtp.Conn) bool {
|
||||
_, ok := c.TLSConnectionState()
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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 +171,19 @@ 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
|
||||
|
||||
// 连接追踪
|
||||
conn *connhub.Conn
|
||||
}
|
||||
|
||||
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
||||
@@ -136,25 +191,44 @@ 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
|
||||
s.conn.Touch()
|
||||
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
|
||||
if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned {
|
||||
s.recordFail("IP已被封禁")
|
||||
return smtp.ErrAuthFailed
|
||||
}
|
||||
|
||||
user, err := s.backend.server.stores.Users.Authenticate(username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries)
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||||
s.backend.server.stores.RecordAuthFailure(
|
||||
s.clientIP,
|
||||
s.backend.server.banCfg.MaxFailAttempts,
|
||||
s.backend.server.banCfg.BanDurationMin,
|
||||
"邮件协议认证失败次数过多",
|
||||
)
|
||||
s.recordFail("用户名或密码错误")
|
||||
return smtp.ErrAuthFailed
|
||||
}
|
||||
|
||||
@@ -166,13 +240,18 @@ 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
|
||||
if s.conn != nil {
|
||||
s.conn.SetUser(s.email)
|
||||
}
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
@@ -180,10 +259,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 +282,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 +294,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)
|
||||
}
|
||||
|
||||
@@ -234,58 +318,78 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
|
||||
// External recipients (authenticated sessions only) are queued for
|
||||
// outbound delivery.
|
||||
func (s *smtpSession) Data(r io.Reader) error {
|
||||
s.conn.Touch()
|
||||
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 {
|
||||
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
|
||||
continue
|
||||
}
|
||||
if err := s.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
|
||||
msg, err := s.saveMessage(user.ID, "INBOX", parsed, data, false)
|
||||
if err != nil {
|
||||
log.Printf("SMTP: failed to create message for %s: %v", rcpt, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("SMTP: message delivered to %s", rcpt)
|
||||
localDelivered++
|
||||
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||
if pusher := s.backend.server.pusher; pusher != nil && msg != nil {
|
||||
pusher.PushNewMessage(user.Username+"@"+user.Domain.Name, msg)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
|
||||
log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.recordDetail(fmt.Sprintf("MAIL FROM:<%s> RCPT×%d 本地投递%d 外发%d",
|
||||
s.from, len(s.rcpts), localDelivered, externalQueued))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -380,7 +484,7 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) error {
|
||||
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, error) {
|
||||
msg := &db.Message{
|
||||
UserID: userID,
|
||||
MessageID: parsed.messageID,
|
||||
@@ -397,7 +501,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
|
||||
Date: parsed.date,
|
||||
}
|
||||
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist attachments to disk and link them to the message so that the
|
||||
@@ -421,7 +525,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
|
||||
}
|
||||
_ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize)
|
||||
}
|
||||
return nil
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Reset clears the session state for the next message on the same connection.
|
||||
@@ -434,5 +538,49 @@ func (s *smtpSession) Reset() {
|
||||
|
||||
// Logout is called when the SMTP connection is closed.
|
||||
func (s *smtpSession) Logout() error {
|
||||
s.writeProtocolLog()
|
||||
if s.conn != nil {
|
||||
s.conn.Close()
|
||||
}
|
||||
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)
|
||||
@@ -83,7 +86,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
if err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
|
||||
if _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
|
||||
t.Fatalf("saveMessage: %v", err)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
)
|
||||
|
||||
// ClientIPFromAddr 从 net.Addr 提取客户端 IP 字符串(去掉端口)。
|
||||
@@ -21,23 +19,50 @@ func ClientIPFromAddr(addr net.Addr) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// RecordAuthFailure 记录一次协议层(SMTP/IMAP/POP3)认证失败:
|
||||
// 失败计数累加,达到 maxFail 阈值时封禁该 IP(封禁时长 minutes 分钟)。
|
||||
// 返回 (是否触发封禁, 当前失败计数)。Web 登录的封禁逻辑在
|
||||
// handlers.AuthHandler 中,与这里独立。
|
||||
func (s *Stores) RecordAuthFailure(ip string, maxFail int, minutes int) (banned bool, failCount int) {
|
||||
// RecordAuthFailure 记录一次登录/认证失败(Web 表单、LDAP 与 SMTP/IMAP/POP3
|
||||
// 协议层统一入口):
|
||||
// - 失败计数累加(每 IP 一条记录,upsert);
|
||||
// - 达到 maxFail 阈值时触发次数 BanCount+1:
|
||||
// 前 freeTriggers(3)次只计数不封禁;
|
||||
// 从第 4 次起封禁,时长按档位递增(stageDuration),上限半年;
|
||||
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
|
||||
//
|
||||
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
|
||||
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string) (banned bool, failCount int) {
|
||||
if ip == "" || maxFail <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
failCount, _ = s.Bans.IncrementFail(ip)
|
||||
if failCount >= maxFail {
|
||||
_ = s.Bans.Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
Reason: fmt.Sprintf("邮件协议认证失败次数过多 (%d次)", failCount),
|
||||
FailCount: failCount,
|
||||
ExpiresAt: time.Now().Add(time.Duration(minutes) * time.Minute),
|
||||
})
|
||||
if failCount < maxFail {
|
||||
return false, failCount
|
||||
}
|
||||
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil || entry == nil {
|
||||
return false, failCount
|
||||
}
|
||||
// 已处于封禁中(例如并发请求竞态)不重复触发、不重设档位
|
||||
if entry.ExpiresAt.After(time.Now()) {
|
||||
return true, failCount
|
||||
}
|
||||
return false, failCount
|
||||
|
||||
banCount := entry.BanCount + 1
|
||||
entry.BanCount = banCount
|
||||
entry.FailCount = failCount
|
||||
|
||||
// 前 3 次只计数,不封禁(保留零到期时间与空原因)
|
||||
if banCount <= freeTriggers {
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
return false, failCount
|
||||
}
|
||||
return false, failCount
|
||||
}
|
||||
|
||||
banNum := banCount - freeTriggers
|
||||
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
|
||||
entry.ExpiresAt = time.Now().Add(stageDuration(banNum, firstBanMin))
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
return false, failCount
|
||||
}
|
||||
return true, failCount
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,52 +19,216 @@ 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)
|
||||
}
|
||||
|
||||
// TestRecordAuthFailureBansAfterThreshold 验证连续认证失败达到阈值后封禁。
|
||||
func TestRecordAuthFailureBansAfterThreshold(t *testing.T) {
|
||||
// TestRecordAuthFailureFreeTriggers 验证前 3 次达到阈值只计数不封禁,
|
||||
// 第 4 次起封禁(第 1 次封禁 = 配置时长)。
|
||||
func TestRecordAuthFailureFreeTriggers(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.10"
|
||||
const maxFail = 3
|
||||
const maxFail = 2
|
||||
|
||||
// 前两次失败不封禁
|
||||
for i := 1; i < maxFail; i++ {
|
||||
banned, count := s.RecordAuthFailure(ip, maxFail, 30)
|
||||
if banned {
|
||||
t.Fatalf("attempt %d should not be banned yet", i)
|
||||
failOnce := func() bool {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多")
|
||||
return banned
|
||||
}
|
||||
|
||||
// 第 1 次触发需要 maxFail 次失败
|
||||
for f := 0; f < maxFail; f++ {
|
||||
if failOnce() {
|
||||
t.Fatalf("trigger 1 (fail %d) should not ban yet", f+1)
|
||||
}
|
||||
if count != i {
|
||||
t.Fatalf("attempt %d: fail count = %d, want %d", i, count, i)
|
||||
}
|
||||
// 达到阈值后失败计数持续累计,之后每次失败都会再次触发
|
||||
for i := 2; i <= 3; i++ {
|
||||
if failOnce() {
|
||||
t.Fatalf("trigger %d should not ban yet", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 第三次失败触发封禁
|
||||
banned, count := s.RecordAuthFailure(ip, maxFail, 30)
|
||||
if !banned {
|
||||
t.Fatal("attempt reaching threshold should ban the IP")
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("get entry: %v", err)
|
||||
}
|
||||
if count != maxFail {
|
||||
t.Fatalf("fail count = %d, want %d", count, maxFail)
|
||||
if entry.BanCount != 3 {
|
||||
t.Fatalf("ban_count = %d, want 3", entry.BanCount)
|
||||
}
|
||||
if !entry.ExpiresAt.IsZero() {
|
||||
t.Fatal("observation record must not have expiry")
|
||||
}
|
||||
|
||||
// 第 4 次触发封禁,时长 = firstBanMin(30 分钟)
|
||||
if !failOnce() {
|
||||
t.Fatal("4th trigger should ban the IP")
|
||||
}
|
||||
|
||||
entry, err = s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("get entry: %v", err)
|
||||
}
|
||||
wantExpiry := time.Now().Add(30 * time.Minute)
|
||||
if entry.ExpiresAt.Before(wantExpiry.Add(-time.Minute)) || entry.ExpiresAt.After(wantExpiry.Add(time.Minute)) {
|
||||
t.Fatalf("ban expiry = %v, want ~%v", entry.ExpiresAt, wantExpiry)
|
||||
}
|
||||
if !strings.Contains(entry.Reason, "第1次封禁") {
|
||||
t.Fatalf("reason = %q, want 第1次封禁", entry.Reason)
|
||||
}
|
||||
if entry.BanCount != 4 {
|
||||
t.Fatalf("ban_count = %d, want 4", entry.BanCount)
|
||||
}
|
||||
|
||||
// IP 现在处于封禁状态
|
||||
banned, entry := s.Bans.IsBanned(ip)
|
||||
if !banned {
|
||||
if banned, _ := s.Bans.IsBanned(ip); !banned {
|
||||
t.Fatal("IP should be banned")
|
||||
}
|
||||
if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) {
|
||||
t.Fatalf("ban expiry too short: %v", entry.ExpiresAt)
|
||||
}
|
||||
|
||||
// TestStagedBanEscalation 验证封禁档位递增:30分钟 → 3小时 → 3个月 → 半年(上限)。
|
||||
func TestStagedBanEscalation(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.11"
|
||||
const maxFail = 2
|
||||
|
||||
failOnce := func() bool {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多")
|
||||
return banned
|
||||
}
|
||||
|
||||
// 第 1 次触发需要 maxFail 次失败;此后每次失败即触发下一轮
|
||||
if failOnce() {
|
||||
t.Fatal("fail 1 must not trigger")
|
||||
}
|
||||
if failOnce() { // 触发 1
|
||||
t.Fatal("trigger 1 must not ban")
|
||||
}
|
||||
if failOnce() { // 触发 2
|
||||
t.Fatal("trigger 2 must not ban")
|
||||
}
|
||||
if failOnce() { // 触发 3
|
||||
t.Fatal("trigger 3 must not ban")
|
||||
}
|
||||
|
||||
// 第 4 次触发:30 分钟
|
||||
if !failOnce() {
|
||||
t.Fatal("trigger 4 should ban")
|
||||
}
|
||||
expectBanDuration(t, s, ip, 4, 30*time.Minute)
|
||||
|
||||
// 第 5 次:3 小时
|
||||
expireBan(t, s, ip)
|
||||
if !failOnce() {
|
||||
t.Fatal("trigger 5 should ban")
|
||||
}
|
||||
expectBanDuration(t, s, ip, 5, 3*time.Hour)
|
||||
|
||||
// 第 6 次:3 个月
|
||||
expireBan(t, s, ip)
|
||||
if !failOnce() {
|
||||
t.Fatal("trigger 6 should ban")
|
||||
}
|
||||
expectBanDuration(t, s, ip, 6, 90*24*time.Hour)
|
||||
|
||||
// 第 7 次:半年
|
||||
expireBan(t, s, ip)
|
||||
if !failOnce() {
|
||||
t.Fatal("trigger 7 should ban")
|
||||
}
|
||||
expectBanDuration(t, s, ip, 7, 180*24*time.Hour)
|
||||
|
||||
// 第 8 次:仍为半年(上限)
|
||||
expireBan(t, s, ip)
|
||||
if !failOnce() {
|
||||
t.Fatal("trigger 8 should ban")
|
||||
}
|
||||
expectBanDuration(t, s, ip, 8, 180*24*time.Hour)
|
||||
|
||||
entry, _ := s.Bans.GetByIP(ip)
|
||||
if !strings.Contains(entry.Reason, "第5次封禁") {
|
||||
t.Fatalf("reason = %q, want 第5次封禁", entry.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// expectBanDuration 断言该 IP 当前封禁时长约为 min(允许 2 分钟误差)。
|
||||
func expectBanDuration(t *testing.T, s *Stores, ip string, trigger int, min time.Duration) {
|
||||
t.Helper()
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("trigger %d: %v", trigger, err)
|
||||
}
|
||||
diff := entry.ExpiresAt.Sub(time.Now())
|
||||
if diff < min-2*time.Minute || diff > min+2*time.Minute {
|
||||
t.Fatalf("trigger %d: ban duration = %v, want ~%v", trigger, diff, min)
|
||||
}
|
||||
}
|
||||
|
||||
// expireBan 把该 IP 的封禁记录改成已过期(模拟时间流逝)。
|
||||
func expireBan(t *testing.T, s *Stores, ip string) {
|
||||
t.Helper()
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("get entry: %v", err)
|
||||
}
|
||||
entry.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
t.Fatalf("update entry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBanListOnlyBannedOrExpired 验证列表只返回封禁(含已过期)记录,
|
||||
// 仅计数的观察记录不出现。
|
||||
func TestBanListOnlyBannedOrExpired(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
|
||||
// 观察记录:失败计数,未封禁(无到期时间)
|
||||
if _, err := s.Bans.IncrementFail("203.0.113.20"); err != nil {
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
// 当前生效的封禁
|
||||
if err := s.Bans.Create(&db.BanEntry{
|
||||
IPAddress: "198.51.100.21",
|
||||
Reason: "第1次封禁:登录失败次数过多(第4次触发,失败5次)",
|
||||
FailCount: 5,
|
||||
BanCount: 4,
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("create ban: %v", err)
|
||||
}
|
||||
// 已过期的封禁(历史)
|
||||
if err := s.Bans.Create(&db.BanEntry{
|
||||
IPAddress: "198.51.100.22",
|
||||
Reason: "第2次封禁:登录失败次数过多(第5次触发,失败6次)",
|
||||
FailCount: 6,
|
||||
BanCount: 5,
|
||||
ExpiresAt: time.Now().Add(-24 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("create expired ban: %v", err)
|
||||
}
|
||||
|
||||
entries, total, err := s.Bans.List(1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("total = %d, want 2", total)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(entries))
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.ExpiresAt.Before(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("observation record leaked into list: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
|
||||
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
banned, count := s.RecordAuthFailure("", 3, 30)
|
||||
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多")
|
||||
if banned || count != 0 {
|
||||
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
|
||||
}
|
||||
@@ -111,3 +276,77 @@ type addrMock string
|
||||
|
||||
func (a addrMock) Network() string { return "tcp" }
|
||||
func (a addrMock) String() string { return string(a) }
|
||||
|
||||
// P3 #13:配额原子预扣——并发/超额场景下不得绕过配额。
|
||||
func TestTryReserveQuota(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
|
||||
// 用户配额 1000
|
||||
user := &db.User{
|
||||
Username: "quota_user",
|
||||
PasswordHash: "x",
|
||||
DomainID: 0,
|
||||
QuotaBytes: 1000,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := s.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
t.Fatal("user ID must be assigned")
|
||||
}
|
||||
|
||||
// 预扣 600 成功
|
||||
ok, err := s.Users.TryReserveQuota(user.ID, 600)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reserve 600: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 再扣 400 正好用完
|
||||
ok, err = s.Users.TryReserveQuota(user.ID, 400)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reserve 400: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 超出配额被拒且不改变 used_bytes
|
||||
ok, err = s.Users.TryReserveQuota(user.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve beyond quota: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("reserve beyond quota must fail")
|
||||
}
|
||||
got, _ := s.Users.GetByID(user.ID)
|
||||
if got.UsedBytes != 1000 {
|
||||
t.Fatalf("used_bytes = %d, want 1000 (no partial charge)", got.UsedBytes)
|
||||
}
|
||||
|
||||
// 释放后可以再次预扣
|
||||
if err := s.Users.UpdateUsedBytes(user.ID, -500); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
ok, err = s.Users.TryReserveQuota(user.ID, 500)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reserve after release: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #13:非正 delta 不允许(防御)。
|
||||
func TestTryReserveQuotaNonPositiveDelta(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
user := &db.User{Username: "u", PasswordHash: "x", QuotaBytes: 100}
|
||||
if err := s.Users.Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, delta := range []int64{0, -10} {
|
||||
ok, err := s.Users.TryReserveQuota(user.ID, delta)
|
||||
if err != nil {
|
||||
t.Fatalf("delta %d: %v", delta, err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("delta %d must not reserve", delta)
|
||||
}
|
||||
}
|
||||
got, _ := s.Users.GetByID(user.ID)
|
||||
if got.UsedBytes != 0 {
|
||||
t.Fatalf("used_bytes = %d, want 0", got.UsedBytes)
|
||||
}
|
||||
}
|
||||
+51
-12
@@ -8,16 +8,48 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁:
|
||||
// 第 4 次 = ban_duration_min(默认 30 分钟)→ 第 5 次 = 3 小时 →
|
||||
// 第 6 次 = 3 个月 → 第 7 次起 = 半年(上限)。
|
||||
const (
|
||||
// freeTriggers 达到失败阈值但暂不封禁的触发次数(前 3 次只计数)。
|
||||
freeTriggers = 3
|
||||
banStage2Min = 3 * 60 // 3 小时
|
||||
banStage3Day = 90 // 3 个月
|
||||
banStage4Day = 180 // 半年(上限)
|
||||
banMaxDay = banStage4Day
|
||||
)
|
||||
|
||||
// stageDuration 返回第 banCount 次封禁(banCount 从 1 开始)的时长。
|
||||
// firstBanMin 是第一次封禁的分钟数(来自配置 [ban] ban_duration_min)。
|
||||
func stageDuration(banCount int, firstBanMin int) time.Duration {
|
||||
switch banCount {
|
||||
case 1:
|
||||
if firstBanMin <= 0 {
|
||||
firstBanMin = 30
|
||||
}
|
||||
return time.Duration(firstBanMin) * time.Minute
|
||||
case 2:
|
||||
return time.Duration(banStage2Min) * time.Minute
|
||||
case 3:
|
||||
return time.Duration(banStage3Day) * 24 * time.Hour
|
||||
default:
|
||||
return time.Duration(banMaxDay) * 24 * time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
// BanStore defines the interface for IP ban operations.
|
||||
type BanStore interface {
|
||||
Create(entry *db.BanEntry) error
|
||||
GetByIP(ip string) (*db.BanEntry, error)
|
||||
Update(entry *db.BanEntry) error
|
||||
Delete(id uint) error
|
||||
// List 返回已封禁或曾封禁的记录(不含仅计数未封禁的观察记录)。
|
||||
List(page, size int) ([]db.BanEntry, int64, error)
|
||||
IsBanned(ip string) (bool, *db.BanEntry)
|
||||
// IncrementFail 累加该 IP 的失败次数(无记录时创建),保留 BanCount。
|
||||
IncrementFail(ip string) (int, error)
|
||||
ResetFail(ip string) error
|
||||
Cleanup() error
|
||||
}
|
||||
|
||||
// banStoreGorm implements BanStore using GORM.
|
||||
@@ -44,22 +76,33 @@ func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// Update saves changes to an existing ban entry record.
|
||||
func (s *banStoreGorm) Update(entry *db.BanEntry) error {
|
||||
return s.db.Save(entry).Error
|
||||
}
|
||||
|
||||
// Delete removes a ban entry by ID.
|
||||
func (s *banStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.BanEntry{}, id).Error
|
||||
}
|
||||
|
||||
// List retrieves a paginated list of ban entries.
|
||||
// banEpochSentinel 用于区分“未封禁的计数记录”(expires_at 为零值):
|
||||
// 所有实际封禁记录的到期时间都晚于 2000 年。
|
||||
var banEpochSentinel = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// List retrieves a paginated list of ban entries that are or have been
|
||||
// banned (expires_at set). 仅计数的观察记录(零到期时间、无原因)不返回。
|
||||
func (s *banStoreGorm) List(page, size int) ([]db.BanEntry, int64, error) {
|
||||
var entries []db.BanEntry
|
||||
var total int64
|
||||
|
||||
if err := s.db.Model(&db.BanEntry{}).Count(&total).Error; err != nil {
|
||||
query := s.db.Model(&db.BanEntry{}).Where("expires_at > ?", banEpochSentinel)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if err := s.db.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil {
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return entries, total, nil
|
||||
@@ -76,7 +119,8 @@ func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
|
||||
}
|
||||
|
||||
// IncrementFail increments the fail count for an IP address.
|
||||
// If no record exists, it creates one with fail_count=1 and a zero expires_at.
|
||||
// If no record exists, it creates one with fail_count=1, ban_count=0 and a
|
||||
// zero expires_at (not yet banned). Existing BanCount is preserved.
|
||||
// Returns the updated fail count.
|
||||
func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||
var entry db.BanEntry
|
||||
@@ -86,6 +130,7 @@ func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||
entry = db.BanEntry{
|
||||
IPAddress: ip,
|
||||
FailCount: 1,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Time{}, // Zero time, not yet banned
|
||||
}
|
||||
if createErr := s.db.Create(&entry).Error; createErr != nil {
|
||||
@@ -103,13 +148,7 @@ func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||
}
|
||||
|
||||
// ResetFail resets the fail count for an IP address by deleting its record.
|
||||
// 成功登录(或管理员解封)后调用:封禁档位历史随之清零。
|
||||
func (s *banStoreGorm) ResetFail(ip string) error {
|
||||
return s.db.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error
|
||||
}
|
||||
|
||||
// Cleanup removes expired ban entries.
|
||||
// It deletes records where expires_at is in the past and is not zero
|
||||
// (preserving records that have fail counts but are not yet banned).
|
||||
func (s *banStoreGorm) Cleanup() error {
|
||||
return s.db.Where("expires_at < ? AND expires_at > ?", time.Now(), time.Time{}).Delete(&db.BanEntry{}).Error
|
||||
}
|
||||
@@ -118,11 +118,14 @@ func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
|
||||
}
|
||||
|
||||
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
||||
// Messages are ordered by ID ascending so that sequence numbers are stable.
|
||||
// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、
|
||||
// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会
|
||||
// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf)
|
||||
// 共用本排序,保证序号全链路一致。
|
||||
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||
var messages []db.Message
|
||||
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
||||
Order("id ASC").Find(&messages).Error; err != nil {
|
||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
|
||||
@@ -15,6 +15,9 @@ type OutboundStore interface {
|
||||
ListDue(now time.Time, limit int) ([]db.OutboundMessage, error)
|
||||
List(page, size int, status string) ([]db.OutboundMessage, int64, error)
|
||||
Update(msg *db.OutboundMessage) error
|
||||
// Claim 原子地将一项待投递邮件置为 sending;仅当该项仍处于
|
||||
// pending/deferred 时成功(并发 worker 抢占,防重复投递)。
|
||||
Claim(id uint) (bool, error)
|
||||
Delete(id uint) error
|
||||
CountByStatus(status string) (int64, error)
|
||||
}
|
||||
@@ -85,6 +88,15 @@ func (s *outboundStoreGorm) Update(msg *db.OutboundMessage) error {
|
||||
return s.db.Save(msg).Error
|
||||
}
|
||||
|
||||
// Claim 原子抢占:把 pending/deferred 项置为 sending。
|
||||
// 返回是否抢占成功(false 表示已被其他 worker 抢先或状态已变化)。
|
||||
func (s *outboundStoreGorm) Claim(id uint) (bool, error) {
|
||||
res := s.db.Model(&db.OutboundMessage{}).
|
||||
Where("id = ? AND status IN (?, ?)", id, db.OutboundStatusPending, db.OutboundStatusDeferred).
|
||||
Update("status", db.OutboundStatusSending)
|
||||
return res.RowsAffected == 1, res.Error
|
||||
}
|
||||
|
||||
// Delete removes an outbound queue record by ID.
|
||||
func (s *outboundStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.OutboundMessage{}, id).Error
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
)
|
||||
|
||||
// TestOutboundClaimAtomic 验证并发抢占同一队列项恰好只有一次成功。
|
||||
func TestOutboundClaimAtomic(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
|
||||
item := &db.OutboundMessage{
|
||||
MessageID: "<t@test>",
|
||||
FromAddr: "a@test.local",
|
||||
ToAddr: "b@fake.test",
|
||||
RecipientDom: "fake.test",
|
||||
RawData: "raw",
|
||||
Status: db.OutboundStatusPending,
|
||||
NextAttemptAt: time.Now(),
|
||||
}
|
||||
if err := s.Outbound.Create(item); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
const n = 10
|
||||
var wins int64
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ok, err := s.Outbound.Claim(item.ID)
|
||||
if err != nil {
|
||||
t.Errorf("claim: %v", err)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
atomic.AddInt64(&wins, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins != 1 {
|
||||
t.Fatalf("claims won = %d, want exactly 1", wins)
|
||||
}
|
||||
got, err := s.Outbound.GetByID(item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Status != db.OutboundStatusSending {
|
||||
t.Fatalf("status = %s, want sending", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboundClaimStatuses 验证只有 pending/deferred 可被抢占。
|
||||
func TestOutboundClaimStatuses(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{db.OutboundStatusPending, true},
|
||||
{db.OutboundStatusDeferred, true},
|
||||
{db.OutboundStatusSending, false},
|
||||
{db.OutboundStatusSent, false},
|
||||
{db.OutboundStatusFailed, false},
|
||||
{db.OutboundStatusCanceled, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
item := &db.OutboundMessage{
|
||||
MessageID: "<t@test>",
|
||||
FromAddr: "a@test.local",
|
||||
ToAddr: "b@fake.test",
|
||||
RecipientDom: "fake.test",
|
||||
RawData: "raw",
|
||||
Status: tc.status,
|
||||
NextAttemptAt: time.Now(),
|
||||
}
|
||||
if err := s.Outbound.Create(item); err != nil {
|
||||
t.Fatalf("create %s: %v", tc.status, err)
|
||||
}
|
||||
ok, err := s.Outbound.Claim(item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("claim %s: %v", tc.status, err)
|
||||
}
|
||||
if ok != tc.want {
|
||||
t.Fatalf("claim %s = %v, want %v", tc.status, ok, tc.want)
|
||||
}
|
||||
if tc.want {
|
||||
got, _ := s.Outbound.GetByID(item.ID)
|
||||
if got.Status != db.OutboundStatusSending {
|
||||
t.Fatalf("claimed %s item must become sending", tc.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -22,6 +22,9 @@ type UserStore interface {
|
||||
ListAll(page, size int) ([]db.User, int64, error)
|
||||
UpdateUsedBytes(id uint, delta int64) error
|
||||
UpdatePassword(userID uint, hashedPassword string) error
|
||||
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
|
||||
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
|
||||
TryReserveQuota(userID uint, delta int64) (bool, error)
|
||||
}
|
||||
|
||||
// userStoreGorm implements UserStore using GORM.
|
||||
@@ -124,6 +127,22 @@ func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
|
||||
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
|
||||
}
|
||||
|
||||
// TryReserveQuota atomically reserves delta bytes for a user within quota.
|
||||
// The reservation is applied (used_bytes incremented) only when it does not
|
||||
// exceed quota_bytes; otherwise no change is made and false is returned.
|
||||
func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error) {
|
||||
if delta <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
res := s.db.Model(&db.User{}).
|
||||
Where("id = ? AND used_bytes + ? <= quota_bytes", userID, delta).
|
||||
Update("used_bytes", gorm.Expr("used_bytes + ?", delta))
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// UpdatePassword updates the password hash for a user and clears the
|
||||
// must-change-password flag (the user has now set their own password).
|
||||
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
||||
|
||||
+184
-10
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/caddycert"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/dkim"
|
||||
"mail_go/internal/outbound"
|
||||
@@ -30,12 +31,76 @@ type AdminHandler struct {
|
||||
tlsDir string
|
||||
caddyDataDir string
|
||||
outbound *outbound.Manager
|
||||
// protocolLogKeepDays SMTP/IMAP/POP3 协议日志保留天数(配置文件 [web])
|
||||
protocolLogKeepDays int
|
||||
// hub 当前协议连接注册中心(「当前连接」页)
|
||||
hub *connhub.Hub
|
||||
}
|
||||
|
||||
// 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, hub *connhub.Hub) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub}
|
||||
}
|
||||
|
||||
// manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
|
||||
const manualBanDuration = 180 * 24 * time.Hour
|
||||
|
||||
// DisconnectConnection 强制断开指定连接并封禁其 IP(管理后台「断开并封禁」)。
|
||||
// 封禁后该 IP 的所有在线连接一并断开。
|
||||
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的连接ID")
|
||||
return
|
||||
}
|
||||
|
||||
conn, ok := h.hub.Get(id)
|
||||
if !ok {
|
||||
c.String(http.StatusNotFound, "连接不存在或已断开")
|
||||
return
|
||||
}
|
||||
|
||||
// 加入黑名单:180 天封禁(管理员可随时解封)
|
||||
if err := h.stores.Bans.Create(&db.BanEntry{
|
||||
IPAddress: conn.IP,
|
||||
Reason: "管理员手动封禁(连接断开)",
|
||||
FailCount: 0,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Now().Add(manualBanDuration),
|
||||
}); err != nil {
|
||||
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 断开该 IP 的全部连接(含本连接与其他协议连接)
|
||||
n := h.hub.DisconnectByIP(conn.IP)
|
||||
log.Printf("admin: 已封禁并断开 IP %s 的 %d 个连接", conn.IP, n)
|
||||
c.Redirect(http.StatusFound, "/admin/connections")
|
||||
}
|
||||
|
||||
// ListConnections 渲染当前协议连接页面(SMTP/IMAP/POP3 实时连接)。
|
||||
func (h *AdminHandler) ListConnections(c *gin.Context) {
|
||||
conns := h.hub.List()
|
||||
counts := h.hub.Counts()
|
||||
|
||||
total := len(conns)
|
||||
smtpCount := counts["smtp"]
|
||||
imapCount := counts["imap"]
|
||||
pop3Count := counts["pop3"]
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_connections", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"conns": conns,
|
||||
"total": total,
|
||||
"smtpCount": smtpCount,
|
||||
"imapCount": imapCount,
|
||||
"pop3Count": pop3Count,
|
||||
"now": time.Now(),
|
||||
"activeFolder": "connections",
|
||||
})
|
||||
}
|
||||
|
||||
// Dashboard renders the admin dashboard with summary statistics.
|
||||
@@ -719,9 +784,6 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||
|
||||
// ListBans renders the IP ban list page.
|
||||
func (h *AdminHandler) ListBans(c *gin.Context) {
|
||||
// Clean up expired entries first
|
||||
h.stores.Bans.Cleanup()
|
||||
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
bans, total, err := h.stores.Bans.List(page, 20)
|
||||
@@ -730,6 +792,13 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 标记当前是否仍处于封禁中
|
||||
now := time.Now()
|
||||
rows := make([]banRow, 0, len(bans))
|
||||
for _, b := range bans {
|
||||
rows = append(rows, banRow{BanEntry: b, Active: b.ExpiresAt.After(now)})
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
@@ -742,7 +811,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
||||
|
||||
c.HTML(200, "admin_bans", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"bans": bans,
|
||||
"rows": rows,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
@@ -751,6 +820,12 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。
|
||||
type banRow struct {
|
||||
db.BanEntry
|
||||
Active bool
|
||||
}
|
||||
|
||||
// UnbanIP removes a ban entry by ID.
|
||||
func (h *AdminHandler) UnbanIP(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -767,10 +842,109 @@ func (h *AdminHandler) UnbanIP(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/admin/bans")
|
||||
}
|
||||
|
||||
// CleanupBans removes all expired ban entries.
|
||||
func (h *AdminHandler) CleanupBans(c *gin.Context) {
|
||||
h.stores.Bans.Cleanup()
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/auth"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -74,19 +73,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
||||
|
||||
user, err := h.stores.Users.Authenticate(email, password)
|
||||
if err != nil {
|
||||
failCount, _ := h.stores.Bans.IncrementFail(ip)
|
||||
|
||||
if failCount >= h.banCfg.MaxFailAttempts {
|
||||
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
|
||||
banEntry := &db.BanEntry{
|
||||
IPAddress: ip,
|
||||
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
|
||||
FailCount: failCount,
|
||||
ExpiresAt: time.Now().Add(banDuration),
|
||||
}
|
||||
h.stores.Bans.Create(banEntry)
|
||||
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多")
|
||||
if banned {
|
||||
entry, _ := h.stores.Bans.GetByIP(ip)
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,11 +93,14 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
||||
// Login successful: reset fail count
|
||||
h.stores.Bans.ResetFail(ip)
|
||||
|
||||
// Set session values
|
||||
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||||
// 供中间件做绝对过期与滑动续期)
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Set("userID", user.ID)
|
||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||
session.Set("isAdmin", user.IsAdmin)
|
||||
session.Set("loginAt", time.Now().Unix())
|
||||
if err := session.Save(); err != nil {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "会话保存失败,请重试",
|
||||
@@ -153,18 +146,10 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("LDAP 认证失败: %v", err)
|
||||
|
||||
failCount, _ := h.stores.Bans.IncrementFail(ip)
|
||||
if failCount >= h.banCfg.MaxFailAttempts {
|
||||
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
|
||||
banEntry := &db.BanEntry{
|
||||
IPAddress: ip,
|
||||
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
|
||||
FailCount: failCount,
|
||||
ExpiresAt: time.Now().Add(banDuration),
|
||||
}
|
||||
h.stores.Bans.Create(banEntry)
|
||||
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多")
|
||||
if banned {
|
||||
entry, _ := h.stores.Bans.GetByIP(ip)
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -203,11 +188,14 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
||||
// Login successful: reset fail count
|
||||
h.stores.Bans.ResetFail(ip)
|
||||
|
||||
// Set session values
|
||||
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||||
// 供中间件做绝对过期与滑动续期)
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Set("userID", user.ID)
|
||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||
session.Set("isAdmin", user.IsAdmin)
|
||||
session.Set("loginAt", time.Now().Unix())
|
||||
if err := session.Save(); err != nil {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "会话保存失败,请重试",
|
||||
@@ -338,11 +326,14 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set session values
|
||||
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||||
// 供中间件做绝对过期与滑动续期)
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Set("userID", user.ID)
|
||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||
session.Set("isAdmin", user.IsAdmin)
|
||||
session.Set("loginAt", time.Now().Unix())
|
||||
if err := session.Save(); err != nil {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "会话保存失败,请重试",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// performPost 发送 POST 请求并返回响应(用于处理器测试)。
|
||||
func performPost(r *gin.Engine, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestDisconnectConnection 验证「断开并封禁」:创建黑名单记录并断开该 IP 全部连接。
|
||||
func TestDisconnectConnection(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.BanEntry{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
hub := connhub.New()
|
||||
|
||||
var closed atomic.Int32
|
||||
// 目标 IP 两个连接(模拟多协议在线)
|
||||
c1 := hub.Register("smtp", "203.0.113.77", 25, false)
|
||||
c1.SetDisconnect(func() { closed.Add(1) })
|
||||
c2 := hub.Register("imap", "203.0.113.77", 993, true)
|
||||
c2.SetDisconnect(func() { closed.Add(1) })
|
||||
// 其他 IP 不应受影响
|
||||
c3 := hub.Register("pop3", "203.0.113.78", 110, false)
|
||||
c3.SetDisconnect(func() { closed.Add(1) })
|
||||
|
||||
h := &AdminHandler{stores: stores, hub: hub}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/admin/connections/:id/disconnect", h.DisconnectConnection)
|
||||
|
||||
rec := performPost(r, "/admin/connections/1/disconnect")
|
||||
if rec.Code != 302 {
|
||||
t.Fatalf("status = %d, want 302", rec.Code)
|
||||
}
|
||||
|
||||
// 该 IP 的两个连接都被断开,其他连接不受影响
|
||||
if closed.Load() != 2 {
|
||||
t.Fatalf("closed = %d, want 2", closed.Load())
|
||||
}
|
||||
if n := hub.Counts()["pop3"]; n != 1 {
|
||||
t.Fatalf("pop3 count = %d, want 1 (unaffected)", n)
|
||||
}
|
||||
|
||||
// 黑名单记录:180 天封禁
|
||||
banned, entry := stores.Bans.IsBanned("203.0.113.77")
|
||||
if !banned {
|
||||
t.Fatal("IP should be banned")
|
||||
}
|
||||
if entry.Reason != "管理员手动封禁(连接断开)" {
|
||||
t.Fatalf("reason = %q", entry.Reason)
|
||||
}
|
||||
wantExpiry := time.Now().Add(180 * 24 * time.Hour)
|
||||
if entry.ExpiresAt.Before(wantExpiry.Add(-time.Minute)) || entry.ExpiresAt.After(wantExpiry.Add(time.Minute)) {
|
||||
t.Fatalf("expiry = %v, want ~180 days", entry.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectConnectionNotFound 验证不存在的连接返回 404。
|
||||
func TestDisconnectConnectionNotFound(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.BanEntry{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
h := &AdminHandler{stores: store.NewStores(gdb), hub: connhub.New()}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/admin/connections/:id/disconnect", h.DisconnectConnection)
|
||||
|
||||
rec := performPost(r, "/admin/connections/999/disconnect")
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
+117
-33
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
@@ -49,12 +51,14 @@ type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
outbound *outbound.Manager
|
||||
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
|
||||
pusher imap_server.Pusher
|
||||
}
|
||||
|
||||
// NewMailHandler creates a new MailHandler with the given stores, attachment
|
||||
// storage and outbound delivery manager.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob}
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, pusher imap_server.Pusher) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, pusher: pusher}
|
||||
}
|
||||
|
||||
// folderCounts returns sidebar badge counts for the current user.
|
||||
@@ -129,7 +133,9 @@ func (h *MailHandler) View(c *gin.Context) {
|
||||
|
||||
// Auto mark as read
|
||||
if !msg.IsRead {
|
||||
_ = h.stores.Mails.MarkRead(uint(id))
|
||||
if err := h.stores.Mails.MarkRead(uint(id)); err != nil {
|
||||
log.Printf("web: 标记已读失败 msg=%d: %v", id, err)
|
||||
}
|
||||
msg.IsRead = true
|
||||
}
|
||||
|
||||
@@ -212,38 +218,60 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
if multipartErr == nil {
|
||||
files := form.File["attachments"]
|
||||
if len(files) > 0 {
|
||||
// Check attachment quota before saving
|
||||
user, _ := h.stores.Users.GetByID(userID)
|
||||
if user != nil {
|
||||
var totalNewSize int64
|
||||
for _, file := range files {
|
||||
totalNewSize += file.Size
|
||||
}
|
||||
if user.UsedBytes+totalNewSize > user.QuotaBytes {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(user.UsedBytes), formatBytes(user.QuotaBytes)),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": user.UsedBytes,
|
||||
"quotaBytes": user.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
// 原子预扣附件配额(单条 SQL:used_bytes + n <= quota_bytes 才生效),
|
||||
// 防止并发提交绕过配额检查(TOCTOU)。后续保存失败会补偿回退。
|
||||
var totalNewSize int64
|
||||
for _, file := range files {
|
||||
totalNewSize += file.Size
|
||||
}
|
||||
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "配额检查失败,请稍后重试",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
if !reserved {
|
||||
user, _ := h.stores.Users.GetByID(userID)
|
||||
usedBytes, quotaBytes := currentUser.UsedBytes, currentUser.QuotaBytes
|
||||
if user != nil {
|
||||
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
|
||||
}
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": usedBytes,
|
||||
"quotaBytes": quotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Read all attachment files into memory once (used for both the
|
||||
// MIME message body and the stored attachment records).
|
||||
// 读取失败的文件回退已预扣的配额。
|
||||
for _, file := range files {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
|
||||
continue
|
||||
}
|
||||
buf, readErr := io.ReadAll(f)
|
||||
f.Close()
|
||||
if readErr != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -360,6 +388,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||
if h.pusher != nil {
|
||||
h.pusher.PushNewMessage(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Save to Sent folder
|
||||
@@ -394,10 +426,12 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Save attachment records linked to the Sent copy (bytes were already
|
||||
// read during message construction).
|
||||
// read during message construction). 配额已在前面原子预扣,
|
||||
// 保存/落库失败的附件需要补偿回退。
|
||||
for _, att := range attachments {
|
||||
relPath, err := h.storage.Save(att.filename, att.data)
|
||||
if err != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -int64(len(att.data)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -408,9 +442,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
ContentType: att.contentType,
|
||||
FileSize: int64(len(att.data)),
|
||||
}
|
||||
_ = h.stores.Attachments.Create(attRecord)
|
||||
// Update user used bytes
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, attRecord.FileSize)
|
||||
if err := h.stores.Attachments.Create(attRecord); err != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -attRecord.FileSize)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/sent")
|
||||
@@ -574,6 +609,16 @@ func (h *MailHandler) Sent(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
|
||||
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
|
||||
// 调用方应回退到默认路径。
|
||||
func safeRedirectPath(referer string) string {
|
||||
if referer == "" || !strings.HasPrefix(referer, "/") || strings.HasPrefix(referer, "//") {
|
||||
return ""
|
||||
}
|
||||
return referer
|
||||
}
|
||||
|
||||
// Delete removes a message by ID after verifying ownership.
|
||||
func (h *MailHandler) Delete(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
@@ -595,11 +640,37 @@ func (h *MailHandler) Delete(c *gin.Context) {
|
||||
_ = h.storage.Delete(att.FilePath)
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
|
||||
}
|
||||
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
||||
_ = h.stores.Mails.Delete(uint(id))
|
||||
if err := h.stores.Attachments.DeleteByMessage(uint(id)); err != nil {
|
||||
log.Printf("web: 删除附件记录失败 msg=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// Redirect back based on the folder
|
||||
referer := c.GetHeader("Referer")
|
||||
// 删除前计算消息在所属文件夹中的序号(用于 Expunge 推送)
|
||||
var seq uint32
|
||||
if msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, msg.Folder); err == nil {
|
||||
for i := range msgs {
|
||||
if msgs[i].ID == uint(id) {
|
||||
seq = uint32(i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h.stores.Mails.Delete(uint(id)); err != nil {
|
||||
log.Printf("web: 删除邮件失败 msg=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// 删除 → 推送给该用户的其他 IMAP 客户端
|
||||
if h.pusher != nil && seq > 0 {
|
||||
userEmail := ""
|
||||
if cu, ok := c.Get("currentUser"); ok {
|
||||
if u, ok := cu.(*db.User); ok {
|
||||
userEmail = u.Username + "@" + u.Domain.Name
|
||||
}
|
||||
}
|
||||
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
|
||||
}
|
||||
|
||||
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
}
|
||||
@@ -623,7 +694,20 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
|
||||
|
||||
_ = h.stores.Mails.MarkRead(uint(id))
|
||||
|
||||
referer := c.GetHeader("Referer")
|
||||
// 已读变化 → 推送给该用户的其他 IMAP 客户端
|
||||
if h.pusher != nil {
|
||||
msg.IsRead = true
|
||||
userEmail := ""
|
||||
if cu, ok := c.Get("currentUser"); ok {
|
||||
if u, ok := cu.(*db.User); ok {
|
||||
userEmail = u.Username + "@" + u.Domain.Name
|
||||
}
|
||||
}
|
||||
h.pusher.PushFlagsChanged(userEmail, msg.Folder, msg)
|
||||
}
|
||||
|
||||
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
}
|
||||
|
||||
@@ -104,3 +104,26 @@ func TestFormatContentDisposition(t *testing.T) {
|
||||
t.Fatalf("CRLF leaked into Content-Disposition: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #12:Referer 开放重定向防护。
|
||||
func TestSafeRedirectPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"/inbox", "/inbox"},
|
||||
{"/mail/delete/5", "/mail/delete/5"},
|
||||
{"/sent?page=2", "/sent?page=2"},
|
||||
{"https://evil.com/", ""},
|
||||
{"//evil.com/inbox", ""},
|
||||
{"http://mail.lmve.net/inbox", ""},
|
||||
{"javascript:alert(1)", ""},
|
||||
{"/\\evil.com", "/\\evil.com"}, // 浏览器对 /\\ 的处理不一致,但不涉及外部协议跳转
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := safeRedirectPath(tc.in); got != tc.want {
|
||||
t.Errorf("safeRedirectPath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package handlers
|
||||
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -32,8 +33,10 @@ func testTemplateFuncs() template.FuncMap {
|
||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||||
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
|
||||
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
|
||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||
"safeJS": func(s string) template.JS { return template.JS(s) },
|
||||
"jsonify": func(v interface{}) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
},
|
||||
"formatBytes": func(b int64) string {
|
||||
return "1 KB"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package web
|
||||
|
||||
// P3 #14:jsonify 模板函数在 <script> 上下文中必须能阻止
|
||||
// </script> 逃逸(encoding/json 默认转义 < > &)。
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJsonifyEscapesScriptBreakout(t *testing.T) {
|
||||
jsonify, ok := templateFuncs()["jsonify"].(func(interface{}) template.JS)
|
||||
if !ok {
|
||||
t.Fatal("template funcs must include jsonify")
|
||||
}
|
||||
|
||||
payloads := []string{
|
||||
`x</script><script>alert(1)</script>`,
|
||||
`"><img src=x onerror=alert(1)>`,
|
||||
"line1\nline2\ttab",
|
||||
"中文内容",
|
||||
`quill "quotes" 'single'`,
|
||||
}
|
||||
for _, p := range payloads {
|
||||
out := jsonify(p)
|
||||
if !strings.HasPrefix(string(out), `"`) || !strings.HasSuffix(string(out), `"`) {
|
||||
t.Errorf("jsonify(%q) = %s, want a quoted JS string literal", p, out)
|
||||
}
|
||||
if strings.Contains(string(out), "</script>") || strings.Contains(string(out), "</SCRIPT>") {
|
||||
t.Errorf("jsonify(%q) must not emit raw </script>: %s", p, out)
|
||||
}
|
||||
if strings.ContainsAny(string(out), "\r\n") {
|
||||
t.Errorf("jsonify(%q) must escape control chars: %q", p, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊值:nil -> null
|
||||
out := jsonify(nil)
|
||||
if string(out) != "null" {
|
||||
t.Errorf("jsonify(nil) = %s, want null", out)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// sessionAbsoluteMaxAge 会话绝对过期时间:超过后强制重新登录。
|
||||
sessionAbsoluteMaxAge = 7 * 24 * time.Hour
|
||||
// sessionSlidingRefresh 滑动续期阈值:距上次刷新超过该时长则更新
|
||||
// loginAt 并写回 cookie,保持活跃用户不中断(约 12 小时写回一次)。
|
||||
sessionSlidingRefresh = 12 * time.Hour
|
||||
)
|
||||
|
||||
// sessionInt64 兼容不同底层 session store 解码出的整数类型。
|
||||
func sessionInt64(v interface{}) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
case uint:
|
||||
return int64(n), true
|
||||
case uint64:
|
||||
return int64(n), true
|
||||
case float64:
|
||||
return int64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMiddleware checks for a valid session and loads the current user
|
||||
// into the Gin context. If no valid session exists, it redirects to /login.
|
||||
func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||
@@ -19,6 +47,23 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 会话绝对过期:登录超过 7 天强制重新登录;
|
||||
// 滑动续期:活跃会话每 12 小时刷新一次 loginAt。
|
||||
if loginAt, ok := sessionInt64(session.Get("loginAt")); ok {
|
||||
elapsed := time.Since(time.Unix(loginAt, 0))
|
||||
if elapsed > sessionAbsoluteMaxAge {
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if elapsed > sessionSlidingRefresh {
|
||||
session.Set("loginAt", time.Now().Unix())
|
||||
session.Save()
|
||||
}
|
||||
}
|
||||
|
||||
// userID is stored as uint in session, but sessions.Get returns interface{}
|
||||
// which may be stored as int or uint depending on the underlying store.
|
||||
var id uint
|
||||
|
||||
@@ -68,6 +68,57 @@ 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_bans", ginH{
|
||||
"currentUser": user, "activeFolder": "bans",
|
||||
"rows": []struct {
|
||||
db.BanEntry
|
||||
Active bool
|
||||
}{
|
||||
{BanEntry: db.BanEntry{IPAddress: "203.0.113.7", BanCount: 4, FailCount: 5, Reason: "第1次封禁:登录失败次数过多(第4次触发,失败5次)", ExpiresAt: now.Add(20 * time.Minute)}, Active: true},
|
||||
{BanEntry: db.BanEntry{IPAddress: "203.0.113.9", BanCount: 5, FailCount: 6, Reason: "第2次封禁:邮件协议认证失败次数过多(第5次触发,失败6次)", ExpiresAt: now.Add(-24 * time.Hour)}, Active: false},
|
||||
{BanEntry: db.BanEntry{IPAddress: "10.0.0.2", BanCount: 1, FailCount: 5, Reason: "", ExpiresAt: time.Time{}}, Active: false},
|
||||
},
|
||||
"total": 3, "page": 1, "pageSize": 20, "totalPages": 1,
|
||||
}},
|
||||
{"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,
|
||||
}},
|
||||
{"admin_connections", ginH{
|
||||
"currentUser": user, "activeFolder": "connections",
|
||||
"conns": []struct {
|
||||
ID uint64
|
||||
Protocol string
|
||||
IP string
|
||||
Port int
|
||||
User string
|
||||
TLS bool
|
||||
Connected time.Time
|
||||
LastActive time.Time
|
||||
}{
|
||||
{ID: 1, Protocol: "smtp", IP: "203.0.113.7", Port: 25, TLS: true, Connected: now.Add(-2 * time.Minute), LastActive: now},
|
||||
{ID: 2, Protocol: "imap", IP: "203.0.113.9", Port: 993, User: "admin", TLS: true, Connected: now.Add(-30 * time.Minute), LastActive: now.Add(-10 * time.Second)},
|
||||
{ID: 3, Protocol: "pop3", IP: "10.0.0.2", Port: 110, User: "alice", TLS: false, Connected: now.Add(-time.Minute), LastActive: now.Add(-30 * time.Second)},
|
||||
},
|
||||
"total": 3, "smtpCount": 1, "imapCount": 1, "pop3Count": 1, "now": now,
|
||||
}},
|
||||
}
|
||||
|
||||
outDir := os.Getenv("MAILGO_PREVIEW_DIR")
|
||||
|
||||
+32
-15
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
@@ -50,17 +53,22 @@ type WebServer struct {
|
||||
banCfg config.BanConfig
|
||||
caddyDataDir string
|
||||
outbound *outbound.Manager
|
||||
hub *connhub.Hub
|
||||
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
|
||||
pusher imap_server.Pusher
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return 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 },
|
||||
"mod": func(a, b int) int { return a % b },
|
||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||||
"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 int64) int64 { return a / b },
|
||||
// durationSeconds 将 time.Duration 转为整秒(模板中无法做类型转换)。
|
||||
"durationSeconds": func(d time.Duration) int64 { return int64(d / time.Second) },
|
||||
"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 {
|
||||
result := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
@@ -71,11 +79,15 @@ func templateFuncs() template.FuncMap {
|
||||
"domainName": func(domainID uint, domains []interface{}) string {
|
||||
return fmt.Sprintf("Domain #%d", domainID)
|
||||
},
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"safeJS": func(s string) template.JS {
|
||||
return template.JS(s)
|
||||
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
|
||||
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
|
||||
// < > &(\u003c 等),无法逃出 </script>,杜绝 script 注入。
|
||||
"jsonify": func(v interface{}) template.JS {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return template.JS(b)
|
||||
},
|
||||
"formatBytes": func(b int64) string {
|
||||
return formatBytes(b)
|
||||
@@ -167,7 +179,7 @@ func avatarStyle(s string) string {
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||
// configures sessions, middleware, and registers all routes.
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager) (*WebServer, error) {
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager, hub *connhub.Hub, pusher imap_server.Pusher) (*WebServer, error) {
|
||||
if err := config.ValidateSecretKey(cfg.SecretKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -213,6 +225,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
banCfg: banCfg,
|
||||
caddyDataDir: caddyCfg.DataDir,
|
||||
outbound: ob,
|
||||
hub: hub,
|
||||
pusher: pusher,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
@@ -222,8 +236,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.pusher)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub)
|
||||
|
||||
// Apply BanMiddleware globally before public routes
|
||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||
@@ -291,7 +305,10 @@ func (ws *WebServer) registerRoutes() {
|
||||
admin.POST("/outbound/:id/cancel", adminHandler.CancelOutbound)
|
||||
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)
|
||||
admin.GET("/connections", adminHandler.ListConnections)
|
||||
admin.POST("/connections/:id/disconnect", adminHandler.DisconnectConnection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
@@ -73,7 +75,7 @@ func newTestWebServer(t *testing.T, secretKey string) (*WebServer, *store.Stores
|
||||
cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey, CookieSecure: true}
|
||||
|
||||
ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir},
|
||||
config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil)
|
||||
config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil, connhub.New(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebServer: %v", err)
|
||||
}
|
||||
@@ -187,10 +189,82 @@ func TestNewWebServerRejectsBadSecretKeys(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewWebServer(config.WebConfig{Addr: "127.0.0.1:0", SecretKey: tc.key},
|
||||
stores, attStorage, config.StorageConfig{BaseDir: baseDir},
|
||||
config.AuthConfig{}, config.BanConfig{}, config.CaddyConfig{}, nil)
|
||||
config.AuthConfig{}, config.BanConfig{}, config.CaddyConfig{}, nil, connhub.New(), nil)
|
||||
if err == nil {
|
||||
t.Fatalf("NewWebServer should reject secret key %q", tc.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSessionCookie 用配置密钥伪造一个签名合法的会话 cookie。
|
||||
// 仅用于测试会话治理逻辑(生产密钥不会泄露)。
|
||||
func encodeSessionCookie(t *testing.T, secretKey string, values map[interface{}]interface{}) string {
|
||||
t.Helper()
|
||||
sc := securecookie.New([]byte(secretKey), nil)
|
||||
enc, err := sc.Encode("mail_go_session", values)
|
||||
if err != nil {
|
||||
t.Fatalf("encode session: %v", err)
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
// authCookieValues 构造 AuthMiddleware 可识别的最小会话内容。
|
||||
func authCookieValues(userID uint, loginAt int64) map[interface{}]interface{} {
|
||||
return map[interface{}]interface{}{
|
||||
"userID": userID,
|
||||
"userEmail": "alice@example.com",
|
||||
"isAdmin": false,
|
||||
"loginAt": loginAt,
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #16:会话绝对过期(7 天)后强制重新登录。
|
||||
func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
ws, _ := newTestWebServer(t, key)
|
||||
srv := httptest.NewServer(ws.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), "/login") {
|
||||
t.Fatalf("expired session should redirect to /login, got %d Location=%q",
|
||||
resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #16:未过期会话(含滑动续期窗口内)正常访问。
|
||||
func TestSessionWithinExpiryWorks(t *testing.T) {
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
ws, _ := newTestWebServer(t, key)
|
||||
srv := httptest.NewServer(ws.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fresh session should access inbox, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -18,24 +18,22 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>IP 黑名单</h2>
|
||||
<form method="POST" action="/admin/bans/cleanup" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary">清理过期记录</button>
|
||||
</form>
|
||||
<span style="color:#7f8c8d;font-size:13px;">阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)</span>
|
||||
</div>
|
||||
{{if not .bans}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无被封禁的 IP</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP 地址</th>
|
||||
<th>状态</th>
|
||||
<th>封禁次数</th>
|
||||
<th>失败次数</th>
|
||||
<th>原因</th>
|
||||
<th>到期时间</th>
|
||||
@@ -43,16 +41,24 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .bans}}
|
||||
{{range .rows}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.IPAddress}}</td>
|
||||
<td>
|
||||
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">封禁中</span>
|
||||
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">已过期</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .BanCount}}
|
||||
{{if gt .BanCount 3}}第{{sub .BanCount 3}}次封禁{{else}}仅计数{{end}}
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
|
||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if not .Active}}(已过期){{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
|
||||
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?');">
|
||||
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
|
||||
<button type="submit" class="btn btn-primary btn-sm">解封</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -60,6 +66,8 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{if not .rows}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无封禁记录</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
@@ -67,7 +75,7 @@
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
<span>第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{{define "admin_connections"}}
|
||||
<!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>
|
||||
<meta http-equiv="refresh" content="5">
|
||||
{{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/connections" {{if eq .activeFolder "connections"}}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>
|
||||
<span style="color:#7f8c8d;font-size:13px;">每 5 秒自动刷新</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<h3>{{.total}}</h3>
|
||||
<p>当前连接总数</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.smtpCount}}</h3>
|
||||
<p>SMTP</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.imapCount}}</h3>
|
||||
<p>IMAP</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.pop3Count}}</h3>
|
||||
<p>POP3</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>协议</th>
|
||||
<th>来源 IP</th>
|
||||
<th>端口</th>
|
||||
<th>用户名</th>
|
||||
<th>TLS</th>
|
||||
<th>连接时间</th>
|
||||
<th>时长</th>
|
||||
<th>最后活跃</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .conns}}
|
||||
<tr>
|
||||
<td>{{.ID}}</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>{{.IP}}</td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
|
||||
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td>
|
||||
<td>{{.Connected.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
|
||||
<td>{{.LastActive.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
|
||||
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">断开并封禁</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -18,7 +18,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
@@ -45,7 +47,7 @@
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
{{if .message.HtmlBody}}
|
||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||
{{else}}
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||
{{end}}
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,132 @@
|
||||
{{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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}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,9 @@
|
||||
<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/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
<span class="detail-label">IP 地址</span>
|
||||
<span class="detail-value">{{.entry.IPAddress}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">封禁档位</span>
|
||||
<span class="detail-value">{{if gt .entry.BanCount 3}}第 {{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">原因</span>
|
||||
<span class="detail-value">{{.entry.Reason}}</span>
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border-light); }
|
||||
|
||||
/* ---------- 通用组件(兼容管理后台) ---------- */
|
||||
.container { max-width: 1400px; margin: 20px auto; padding: 0 20px; }
|
||||
.container { max-width: 1400px; margin: 20px auto; padding: var(--topbar-h) 20px 0; }
|
||||
.card {
|
||||
background: #fff; border: 1px solid var(--border-light); border-radius: var(--radius);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); padding: 20px; margin-bottom: 16px;
|
||||
@@ -416,7 +416,7 @@
|
||||
.settings-main { padding: 14px; }
|
||||
|
||||
/* 管理后台 */
|
||||
.container { padding: 0 12px; margin: 12px auto; }
|
||||
.container { padding: var(--topbar-h) 12px 0; margin: 12px auto; }
|
||||
.sidebar { float: none; width: 100%; margin-bottom: 12px; display: flex; flex-wrap: wrap; gap: 2px; padding: 6px; }
|
||||
.sidebar a { margin: 0; padding: 8px 12px; font-size: 13px; }
|
||||
.content { margin-left: 0; overflow-x: auto; }
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
}
|
||||
});
|
||||
{{if .bodyContent}}
|
||||
quill.root.innerHTML = {{.bodyContent | safeJS}};
|
||||
quill.root.innerHTML = {{.bodyContent | jsonify}};
|
||||
{{end}}
|
||||
|
||||
document.getElementById('compose-form').addEventListener('submit', function () {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="mail-body-wrap">
|
||||
<div class="mail-body">
|
||||
{{if .message.HtmlBody}}
|
||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||
{{else}}
|
||||
<pre>{{.message.TextBody}}</pre>
|
||||
{{end}}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/outbound"
|
||||
@@ -237,8 +238,27 @@ func main() {
|
||||
fmt.Println("外发邮件投递未启用(outbound.max_per_day = 0)")
|
||||
}
|
||||
|
||||
// 7. Start SMTP server
|
||||
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban)
|
||||
// 6. 连接注册中心(后台「当前连接」页 + IMAP 新邮件推送)
|
||||
connHub := connhub.New()
|
||||
|
||||
// 7. Start IMAP server(先于 SMTP 创建,SMTP 投递成功时通知其推送)
|
||||
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub)
|
||||
go func() {
|
||||
if err := imapSrv.Start(); err != nil {
|
||||
log.Printf("IMAP 服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
// Start IMAPS if TLS is configured
|
||||
if cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" {
|
||||
go func() {
|
||||
if err := imapSrv.StartTLS(); err != nil {
|
||||
log.Printf("IMAPS 服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 8. Start SMTP server(本地投递成功后触发 IMAP 新邮件推送)
|
||||
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban, connHub, imapSrv)
|
||||
go func() {
|
||||
if err := smtpSrv.Start(); err != nil {
|
||||
log.Printf("SMTP 服务启动失败: %v", err)
|
||||
@@ -258,24 +278,8 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// 7. Start IMAP server
|
||||
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban)
|
||||
go func() {
|
||||
if err := imapSrv.Start(); err != nil {
|
||||
log.Printf("IMAP 服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
// Start IMAPS if TLS is configured
|
||||
if cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" {
|
||||
go func() {
|
||||
if err := imapSrv.StartTLS(); err != nil {
|
||||
log.Printf("IMAPS 服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 8. Start POP3 server
|
||||
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban)
|
||||
// 9. Start POP3 server
|
||||
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub, imapSrv)
|
||||
go func() {
|
||||
if err := pop3Srv.Start(); err != nil {
|
||||
log.Printf("POP3 服务启动失败: %v", err)
|
||||
@@ -290,8 +294,8 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// 10. Start Web server
|
||||
webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr)
|
||||
// 10. Start Web server(本地写信投递成功后同样触发 IMAP 新邮件推送)
|
||||
webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr, connHub, imapSrv)
|
||||
if err != nil {
|
||||
log.Fatalf("Web 服务初始化失败: %v", err)
|
||||
}
|
||||
@@ -302,10 +306,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 +379,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 {
|
||||
|
||||
+23
-12
@@ -140,29 +140,36 @@
|
||||
|
||||
### 12. Referer 开放重定向
|
||||
|
||||
- [ ] 位置:`internal/web/handlers/mail.go:567-571、591-595`
|
||||
- [ ] 修复:仅接受以 `/` 开头且非 `//` 的相对路径 Referer,否则回退 `/inbox`。
|
||||
- [x] 位置:`internal/web/handlers/mail.go`(Delete/MarkRead)
|
||||
- [x] 修复:新增 `safeRedirectPath`——仅接受以 `/` 开头且非 `//` 的同站相对路径,外部 URL/协议跳转一律回退 `/inbox`。
|
||||
- 验证:
|
||||
- [x] 单测:`https://evil.com/`、`//evil.com`、`javascript:` 等拒绝,相对路径放行(`TestSafeRedirectPath`)。
|
||||
|
||||
### 13. Web 发信配额检查 TOCTOU
|
||||
|
||||
- [ ] 位置:`internal/web/handlers/mail.go:209-263`
|
||||
- [ ] 修复:配额检查与 `UpdateUsedBytes` 改为单条原子 SQL(`WHERE used_bytes + ? <= quota_bytes` 式更新),失败即拒发。
|
||||
- [x] 位置:`internal/web/handlers/mail.go`、`internal/store/user_store.go`
|
||||
- [x] 修复:新增 `UserStore.TryReserveQuota`(单条原子 SQL `UPDATE ... WHERE used_bytes + ? <= quota_bytes`),DoSend 先原子预扣全部附件大小,超配额即拒发;后续读取/保存/落库失败的附件按大小补偿回退。
|
||||
- 验证:
|
||||
- [x] 单测:预扣到配额上限、超额拒绝且不部分扣费、释放后可再扣、非正 delta 拒绝(`TestTryReserveQuota*`)。
|
||||
|
||||
### 14. compose 页 safeJS 在 JS 上下文绕过转义(自 XSS)
|
||||
|
||||
- [ ] 位置:`internal/web/templates/compose.html:80`
|
||||
- [ ] 修复:改为 `quill.root.innerHTML = {{.bodyContent | jsonify}};`(模板函数内用 `json.Marshal` 输出 JS 字符串字面量)。
|
||||
- [ ] 顺手评估移除 `templateFuncs` 中不再使用的 `safeHTML`,缩小危险面。
|
||||
- [x] 位置:`internal/web/templates/compose.html`、`internal/web/server.go`
|
||||
- [x] 修复:新增 `jsonify` 模板函数(`json.Marshal`,默认转义 `< > &` 为 `\u003c` 等,无法逃出 `</script>`);`quill.root.innerHTML` 改用 `jsonify`。**移除** `templateFuncs` 中危险的 `safeHTML`/`safeJS`;view/admin 模板的 `srcdoc` 改回默认属性转义(行为一致,前已实测)。
|
||||
- 验证:
|
||||
- [x] 单测:`</script>` 载荷不产生裸逃逸、控制字符转义、输出为合法字符串字面量(`TestJsonifyEscapesScriptBreakout`);全模板渲染测试通过。
|
||||
|
||||
### 15. Content-Disposition 文件名未编码
|
||||
|
||||
- [ ] 位置:`internal/web/handlers/mail.go:626`、`internal/web/handlers/admin.go:863`
|
||||
- [ ] 修复:与 #4 一并改用 `mime.FormatMediaType`(RFC 5987 `filename*=`)。
|
||||
- [x] 位置:`internal/web/handlers/mail.go`、`internal/web/handlers/admin.go`
|
||||
- [x] 修复:随 P1 #4 一并完成——`formatContentDisposition` 使用 `mime.FormatMediaType`(RFC 2231),两处下载端点均已应用,并有 `TestFormatContentDisposition` 覆盖。
|
||||
|
||||
### 16. 会话治理
|
||||
|
||||
- [ ] 登录成功后调用 `session.Clear()` 再写入新值(清掉可能的旧状态)。
|
||||
- [ ] 会话固定时长 24h 无任何续期/空闲过期策略,考虑加滑动过期与绝对过期。
|
||||
- [x] 位置:`internal/web/handlers/auth.go`、`internal/web/middleware/auth.go`
|
||||
- [x] 修复:登录成功(Web/LDAP/OAuth2 三处)先 `session.Clear()` 清旧状态再写入;会话记录 `loginAt`,AuthMiddleware 实施**绝对过期 7 天**(超时强制登出)+ **滑动续期**(活跃会话每 12 小时写回刷新)。
|
||||
- 验证:
|
||||
- [x] 单测:8 天前的会话被重定向登录页;1 小时前的会话正常访问(用配置密钥签名构造会话,`TestSessionAbsoluteExpiryForcesRelogin`/`TestSessionWithinExpiryWorks`)。
|
||||
|
||||
## 已确认安全、无需改动
|
||||
|
||||
@@ -177,4 +184,8 @@
|
||||
1. ~~#1(P0)~~ 已完成 2026-08-19
|
||||
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
||||
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
||||
4. 其余 P3 项随版本迭代
|
||||
4. ~~#12-#16(P3)~~ 已完成 2026-08-19
|
||||
|
||||
**全部安全审计项已修复完成。** 剩余建议(非代码项):
|
||||
- 部署侧:Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达、GitHub 仓库中 3 个 50MB+ 的 exe 文件建议改用 LFS 或删除
|
||||
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)
|
||||
Reference in New Issue
Block a user