Files
mailgo/internal/db/models.go
T
kevin 6ee2dd8184 fix(security): 修复 P4 封禁记录数据错位 + P5 枚举爆破宽限(方案 A)
P4 #17 手动封禁 Create 非 upsert 导致数据错位:
- BanStore 新增 BanIP(ip, reason, duration):事务内清理该 IP 全部
  既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零;
  DisconnectConnection 改用(原裸 Create 为全仓库唯一调用点)
- BanEntry.IPAddress 升级 uniqueIndex;InitDB 在 AutoMigrate 前
  dedupeBanEntries 清理历史重复行(MySQL 1093 兼容写法)
- IncrementFail 原子化:SQL 侧 fail_count+1,miss 时 OnConflict
  DoNothing 插入兜底并发竞态,回读计数

P5 #18 方案 A(按失败性质区分宽限):
- RecordAuthFailure 新增 knownUser 参数:用户名不存在(枚举型
  爆破)跳过 3 次宽限、首次触发即封第 1 档;用户名存在(真实
  用户输错)保留宽限防误封
- 新增 UserStore.LoginExists(邮箱/裸用户名);五个失败调用点
  接线(Web 登录查存在性;SMTP/IMAP/POP3 用登录名;LDAP 侧
  存在性不可判定,保守按已知用户处理)
- 封禁原因注明「未知用户名,跳过宽限」便于后台审计

新增 8 个测试(-race 通过):BanIP 单行 upsert、唯一索引约束、
16 协程并发计数精确、dedupe 清理/表不存在静默、未知用户即时
封禁、已知用户宽限回归、LoginExists 矩阵。
2026-08-20 11:25:10 +08:00

232 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
import (
"time"
)
// User represents a mail user in the system.
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
DomainID uint `gorm:"index" json:"domain_id"`
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
// 用户)在首次登录后必须修改密码。
MustChangePassword bool `gorm:"default:false" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for User.
func (User) TableName() string {
return "users"
}
// Domain represents a mail domain in the system.
type Domain struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
SmtpPort int `gorm:"default:25" json:"smtp_port"`
ImapPort int `gorm:"default:143" json:"imap_port"`
Pop3Port int `gorm:"default:110" json:"pop3_port"`
TlsCertPath string `gorm:"size:512" json:"tls_cert_path"`
TlsKeyPath string `gorm:"size:512" json:"tls_key_path"`
TlsEnabled bool `gorm:"default:false" json:"tls_enabled"`
DkimSelector string `gorm:"size:64;default:default" json:"dkim_selector"`
DkimPrivateKey string `gorm:"size:4096" json:"-"`
DkimPublicKey string `gorm:"size:1024" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for Domain.
func (Domain) TableName() string {
return "domains"
}
// Message represents an email message in the system.
type Message struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user"`
MessageID string `gorm:"size:255;index" json:"message_id"`
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
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: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"`
// IsDeleted 持久化 IMAP \Deleted 标记(STORE +FLAGS \Deleted 写入,
// EXPUNGE/UID EXPUNGE 时按此删除)。此前该标记只存于内存会话
// imapMailbox.deleted map),重选文件夹/重连即丢失,导致客户端
// COPY→Trash 后 EXPUNGE 删不掉原邮件(垃圾桶只有副本)。
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
Date time.Time `json:"date"`
CreatedAt time.Time `json:"created_at"`
}
// TableName specifies the table name for Message.
func (Message) TableName() string {
return "messages"
}
// Outbound message delivery statuses.
const (
OutboundStatusPending = "pending" // 等待发送
OutboundStatusSending = "sending" // 发送中
OutboundStatusSent = "sent" // 已送达
OutboundStatusDeferred = "deferred" // 临时失败,等待重试
OutboundStatusFailed = "failed" // 永久失败/超过重试上限
OutboundStatusCanceled = "canceled" // 管理员取消
)
// OutboundMessage represents a message queued for delivery to an external domain.
type OutboundMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
MessageID string `gorm:"size:255;index" json:"message_id"`
UserID uint `gorm:"index" json:"user_id"` // 发件用户 IDWeb/SMTP 提交用户)
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
ToAddr string `gorm:"size:512;not null" json:"to_addr"`
RecipientDom string `gorm:"size:255;index" json:"recipient_dom"`
RawData string `gorm:"type:mediumtext" json:"-"` // DKIM 签名后的完整邮件
Status string `gorm:"size:32;default:pending;index" json:"status"`
Attempts int `gorm:"default:0" json:"attempts"`
NextAttemptAt time.Time `gorm:"index" json:"next_attempt_at"`
LastResponse string `gorm:"size:1024" json:"last_response"`
LastError string `gorm:"size:1024" json:"last_error"`
CompletedAt *time.Time `json:"completed_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for OutboundMessage.
func (OutboundMessage) TableName() string {
return "outbound_messages"
}
// BanEntry represents an IP address that has been banned due to excessive login failures.
type BanEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
// IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
// 防止并发写入产生重复行导致计数与档位读写错位。
IPAddress string `gorm:"size:45;uniqueIndex;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"`
}
// 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"`
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"`
FileSize int64 `json:"file_size"`
CreatedAt time.Time `json:"created_at"`
}
// TableName specifies the table name for Attachment.
func (Attachment) TableName() string {
return "attachments"
}
// SystemMailbox 定义一个系统预置文件夹(RFC 6154 SPECIAL-USE 角色)。
type SystemMailbox struct {
Name string // 规范名(INBOX 大小写不敏感)
SpecialUse string // Sent / Drafts / TrashINBOX 为 ""
}
// SystemMailboxes 是系统预置文件夹清单:IMAP LIST 与 Web 侧边栏
// 共用此定义,保证「IMAP 返回什么,Web 就显示什么」。
var SystemMailboxes = []SystemMailbox{
{Name: "INBOX", SpecialUse: ""},
{Name: "Sent", SpecialUse: "Sent"},
{Name: "Drafts", SpecialUse: "Drafts"},
{Name: "Trash", SpecialUse: "Trash"},
}
// Mailbox 表示一个用户文件夹(IMAP mailbox / Web 侧边栏条目)。
// 系统文件夹在首次访问时由 MailboxStore.EnsureSystem 幂等创建;
// 自定义文件夹经 IMAP CREATE 创建。
type Mailbox struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_mailbox_user_name;not null" json:"user_id"`
Name string `gorm:"size:64;uniqueIndex:idx_mailbox_user_name;not null" json:"name"`
SpecialUse string `gorm:"size:16" json:"special_use"` // 自定义文件夹为空
IsSubscribed bool `gorm:"default:true" json:"is_subscribed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for Mailbox.
func (Mailbox) TableName() string {
return "mailboxes"
}
// MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。
// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间
// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存
// 并全量重新同步。此前硬编码为 1,数据库重建后客户端缓存永不失效,
// 导致只显示/下载少量"缺失"邮件。
type MailboxState struct {
UserID uint `gorm:"primaryKey" json:"user_id"`
Folder string `gorm:"primaryKey;size:64" json:"folder"`
UidValidity uint32 `gorm:"not null" json:"uid_validity"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for MailboxState.
func (MailboxState) TableName() string {
return "mailbox_states"
}