Author SHA1 Message Date
dsh d9bb3cbf3f fix(store): 邮件日期统一 UTC 存储,修复混合时区偏移导致 Web/IMAP 排序错乱
根因:date 列在 SQLite 中是文本,历史数据混存 +08:00/-04:00/+00:00
等时区偏移,ORDER BY date DESC 变成字符串字典序而非时间序:
- Web 列表最新邮件排不到最前(如 22:21+08 的邮件排在 20:03+08 后面)
- IMAP 序号与客户端按日期排序的视图不一致

修复:
- mailStore.Create 统一 msg.Date = msg.Date.UTC()(所有写入路径
  共用:SMTP/IMAP APPEND/Web/外发回执),新数据字典序即时间序
- 生产数据一次性迁移为 UTC 格式(130 条,Python 全量解析转换)
- 验证:kevin INBOX 排序 172→170→166→164…(严格最新在前)
2026-08-19 10:35:50 -04:00
dsh f48776e89b fix(auth): 协议登录(IMAP/SMTP/POP3)成功时清零失败计数 + 支持裸用户名
问题:ResetFail 只在 Web 登录调用,协议层失败计数只增不减。
合法用户的客户端(手机 APP 用裸用户名重试、配置向导探测、输错
密码等)失败次数持续累积,每达到阈值就触发一次封禁档位,从第 4
次触发起真实封禁 30 分钟+——用户被反复误封,手机端表现为一直
卡在"正在接收邮件"。

修复:
- IMAP/SMTP/POP3 认证成功路径调用 Bans.ResetFail(与 Web 一致)
- 新增 UserStore.AuthenticateLogin:支持裸用户名(如 "kevin"),
  唯一归属时自动解析到其域名;跨域名同名歧义时要求完整邮箱
- 新增 TestAuthenticateLoginBareUsername 单元测试
2026-08-19 10:12:28 -04:00
dsh 6d5a7a8ed4 fix(imap): mailbox_states 表加入 AutoMigrate(此前漏迁移导致 UIDVALIDITY 回退 1) 2026-08-19 09:45:39 -04:00
dsh 7ede29a18a fix(imap): UIDVALIDITY 改为持久化随机值,修复数据库重建后客户端缓存永不失效
根因:Status() 硬编码 UidValidity=1。数据库重建后消息 ID 空间
完全变化(uid 2-166 对应完全不同的邮件),但 UIDVALIDITY 不变,
Thunderbird 认为本地缓存(旧 uid 1-166)仍然有效,只下载"新增"
(uid 167:*)与"缺失"(2,4,8,9)的少数邮件——列表只剩 1-3 封,
每次刷新数量随机。

修复(RFC 3501 UIDVALIDITY 语义):新增 mailbox_states 表,
每(用户,文件夹)首次访问随机生成并持久化 UIDVALIDITY;数据库
重建后新值触发客户端丢弃缓存全量重同步。0 值修正,不同用户/
文件夹互相独立。

新增 TestMailboxStateUidValidity 单元测试。
2026-08-19 09:36:55 -04:00
13 changed files with 276 additions and 273 deletions
-1
View File
@@ -40,4 +40,3 @@ win/srv/
# 临时测试文件
login_test.html
migrate
-260
View File
@@ -1,260 +0,0 @@
// migrate 一次性工具:把 SQLite 数据迁移到 MySQLmailgo 库)。
// 用法: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")
}
// 目标:MySQLInitDB 内含 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 → 其余
// 所有时间统一 UTCMySQL 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_entriesexpires_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("迁移完成 ✅")
}
+1 -1
View File
@@ -56,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{}, &ProtocolLog{}); err != nil {
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
+21 -4
View File
@@ -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:mediumtext" json:"text_body"`
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
TextBody string `gorm:"type:text" json:"text_body"`
HtmlBody string `gorm:"type:text" 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"`
@@ -155,11 +155,10 @@ func (ProtocolLog) TableName() string {
}
// 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"`
@@ -171,3 +170,21 @@ type Attachment struct {
func (Attachment) TableName() string {
return "attachments"
}
// 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"
}
+16 -5
View File
@@ -145,7 +145,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
return nil, backend.ErrInvalidCredentials
}
user, err := b.stores.Users.Authenticate(username, password)
user, err := b.stores.Users.AuthenticateLogin(username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
@@ -153,6 +153,11 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
return nil, fmt.Errorf("invalid credentials: %w", err)
}
// 登录成功清零失败计数(与 Web 登录一致):否则协议客户端的失败计数
// 只增不减(如配置探测、输错密码、APP 用裸用户名重试等),累计触发
// 档位封禁,合法用户 IP 被反复误封。
b.stores.Bans.ResetFail(clientIP)
email := user.Username + "@"
domain, err := b.stores.Domains.GetByID(user.DomainID)
if err == nil {
@@ -354,7 +359,6 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
if err != nil {
return nil, err
}
status.Messages = uint32(len(messages))
var unseenCount uint32
@@ -370,7 +374,16 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
return nil, err
}
status.UidNext = uint32(maxID + 1)
status.UidValidity = 1
// UIDVALIDITY 持久化随机值(RFC 3501):数据库重建导致消息 ID 空间
// 变化时该值随之改变,客户端才会丢弃旧缓存全量重同步。此前硬编码 1,
// 数据库重建后 Thunderbird 等客户端缓存永不失效(只下载"新增"的
// UID),表现为列表只剩少量邮件。
uidValidity, err := m.stores.MailboxState.UidValidity(m.user.id, m.name)
if err != nil {
log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", m.name, err)
uidValidity = 1
}
status.UidValidity = uidValidity
return status, nil
}
@@ -394,7 +407,6 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F
if err != nil {
return err
}
if len(dbMessages) == 0 {
return nil
}
@@ -567,7 +579,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([
}
}
}
return results, nil
}
+5 -1
View File
@@ -395,7 +395,7 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
authUser, err := s.stores.Users.Authenticate(user.Username, password)
authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
@@ -403,6 +403,10 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
return nil, nil, nil
}
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.stores.Bans.ResetFail(clientIP)
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
authUser.Username = user.Username
+5 -1
View File
@@ -219,7 +219,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return smtp.ErrAuthFailed
}
user, err := s.backend.server.stores.Users.Authenticate(username, password)
user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password)
if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.backend.server.stores.RecordAuthFailure(
@@ -232,6 +232,10 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return smtp.ErrAuthFailed
}
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.backend.server.stores.Bans.ResetFail(s.clientIP)
domainName := user.Domain.Name
if domainName == "" {
domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID)
+7
View File
@@ -52,6 +52,13 @@ func newMailStore(database *gorm.DB) MailStore {
// Create inserts a new message record.
func (s *mailStoreGorm) Create(msg *db.Message) error {
// 日期统一为 UTC 存储:date 列在 SQLite 中是文本,混合时区偏移
// +08:00/-04:00 等)会让 ORDER BY date 变成错误的字典序(Web 列表
// 排序错乱、IMAP 序号与客户端日期视图不一致)。统一 UTC 后字典序
// 即时间序,所有排序路径(Web/IMAP/seqOf)全链路一致。
if msg != nil {
msg.Date = msg.Date.UTC()
}
return s.db.Create(msg).Error
}
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"crypto/rand"
"encoding/binary"
"mail_go/internal/db"
"gorm.io/gorm"
)
// MailboxStateStore 提供 IMAP 邮箱持久化状态(UIDVALIDITY)的存取。
type MailboxStateStore interface {
// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。
UidValidity(userID uint, folder string) (uint32, error)
}
type mailboxStateStoreGorm struct {
db *gorm.DB
}
func newMailboxStateStore(database *gorm.DB) *mailboxStateStoreGorm {
return &mailboxStateStoreGorm{db: database}
}
// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。
// 随机值保证:邮箱内容身份变化(如数据库重建导致消息 ID 空间变化)时,
// 新库生成的新值会让客户端丢弃旧缓存全量重同步(RFC 3501 UIDVALIDITY
// 语义)。绝不返回 00 不是合法 UIDVALIDITY)。
func (s *mailboxStateStoreGorm) UidValidity(userID uint, folder string) (uint32, error) {
var st db.MailboxState
err := s.db.Where("user_id = ? AND folder = ?", userID, folder).First(&st).Error
if err == nil {
if st.UidValidity != 0 {
return st.UidValidity, nil
}
} else if err != gorm.ErrRecordNotFound {
return 0, err
}
// 首次访问(或旧数据为 0):随机生成并持久化
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, err
}
v := binary.BigEndian.Uint32(buf[:])
if v == 0 {
v = 1
}
st = db.MailboxState{UserID: userID, Folder: folder, UidValidity: v}
if err := s.db.Save(&st).Error; err != nil {
return 0, err
}
return v, nil
}
@@ -0,0 +1,56 @@
package store
import (
"testing"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestMailboxStateUidValidity 验证 UIDVALIDITY:首次访问随机生成、重复访问
// 稳定返回、0 值被修正、不同邮箱互不影响。
func TestMailboxStateUidValidity(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.MailboxState{}); err != nil {
t.Fatal(err)
}
s := newMailboxStateStore(gdb)
// 首次访问:随机非 0
v1, err := s.UidValidity(1, "INBOX")
if err != nil {
t.Fatal(err)
}
if v1 == 0 {
t.Fatal("UIDVALIDITY 不应为 0")
}
// 重复访问:稳定
v2, err := s.UidValidity(1, "INBOX")
if err != nil {
t.Fatal(err)
}
if v1 != v2 {
t.Fatalf("UIDVALIDITY 不稳定: %d != %d", v1, v2)
}
// 不同邮箱:独立
v3, err := s.UidValidity(1, "Sent")
if err != nil {
t.Fatal(err)
}
if v3 == v1 {
t.Fatal("不同邮箱的 UIDVALIDITY 不应相同")
}
// 不同用户:独立
v4, err := s.UidValidity(2, "INBOX")
if err != nil {
t.Fatal(err)
}
if v4 == v1 {
t.Fatal("不同用户的 UIDVALIDITY 不应相同")
}
}
+3
View File
@@ -15,6 +15,7 @@ type Stores struct {
Bans BanStore
Outbound OutboundStore
ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore
}
// NewStores creates a new Stores instance with all GORM-backed implementations.
@@ -27,6 +28,7 @@ func NewStores(database *gorm.DB) *Stores {
Bans: newBanStore(database),
Outbound: newOutboundStore(database),
ProtocolLogs: newProtocolLogStore(database),
MailboxState: newMailboxStateStore(database),
}
}
@@ -37,3 +39,4 @@ var _ = db.Message{}
var _ = db.Attachment{}
var _ = db.BanEntry{}
var _ = db.ProtocolLog{}
var _ = db.MailboxState{}
+33
View File
@@ -16,6 +16,10 @@ type UserStore interface {
GetByUsername(username string, domainID uint) (*db.User, error)
GetByEmail(email string) (*db.User, error)
Authenticate(email, password string) (*db.User, error)
// AuthenticateLogin 协议层登录(IMAP/SMTP/POP3):与 Authenticate 相同,
// 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下
// 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。
AuthenticateLogin(login, password string) (*db.User, error)
Update(user *db.User) error
Delete(id uint) error
List(domainID uint, page, size int) ([]db.User, int64, error)
@@ -94,6 +98,35 @@ func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) {
return user, nil
}
// AuthenticateLogin 协议层登录:优先按完整邮箱认证;裸用户名(无 @)时
// 按用户名全局查找,仅在唯一归属时接受(多域名同名视为歧义,返回失败,
// 客户端应改用完整邮箱)。密码校验与 IsActive 逻辑与 Authenticate 一致。
func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, error) {
if strings.Contains(login, "@") {
return s.Authenticate(login, password)
}
var users []db.User
if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id").
Where("users.username = ?", login).
Preload("Domain").
Find(&users).Error; err != nil {
return nil, ErrInvalidCredentials
}
if len(users) != 1 {
// 0 个:用户不存在;多个:跨域名同名歧义,要求完整邮箱
return nil, ErrInvalidCredentials
}
user := users[0]
if !user.IsActive {
return nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return &user, nil
}
// Update saves changes to an existing user record.
func (s *userStoreGorm) Update(user *db.User) error {
return s.db.Save(user).Error
@@ -0,0 +1,73 @@
package store
import (
"testing"
"mail_go/internal/db"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestAuthenticateLoginBareUsername 验证协议层登录支持裸用户名:
// 唯一归属时可用,密码错误/用户不存在/跨域名同名歧义时拒绝。
func TestAuthenticateLoginBareUsername(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}); err != nil {
t.Fatal(err)
}
us := newUserStore(gdb)
ds := newDomainStore(gdb)
dom1 := &db.Domain{Name: "example.com"}
if err := ds.Create(dom1); err != nil {
t.Fatal(err)
}
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
u1 := &db.User{Username: "alice", DomainID: dom1.ID, PasswordHash: string(hashed), IsActive: true}
if err := us.Create(u1); err != nil {
t.Fatal(err)
}
// 裸用户名 + 正确密码 → 成功
u, err := us.AuthenticateLogin("alice", "secret123")
if err != nil {
t.Fatalf("bare username should succeed: %v", err)
}
if u.ID != u1.ID {
t.Fatalf("wrong user: %d != %d", u.ID, u1.ID)
}
// 裸用户名 + 错误密码 → 失败
if _, err := us.AuthenticateLogin("alice", "wrong"); err == nil {
t.Fatal("wrong password should fail")
}
// 不存在 → 失败
if _, err := us.AuthenticateLogin("nobody", "secret123"); err == nil {
t.Fatal("unknown user should fail")
}
// 完整邮箱仍然可用
if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil {
t.Fatalf("full email should succeed: %v", err)
}
// 跨域名同名 → 歧义拒绝
dom2 := &db.Domain{Name: "other.com"}
if err := ds.Create(dom2); err != nil {
t.Fatal(err)
}
u2 := &db.User{Username: "alice", DomainID: dom2.ID, PasswordHash: string(hashed), IsActive: true}
if err := us.Create(u2); err != nil {
t.Fatal(err)
}
if _, err := us.AuthenticateLogin("alice", "secret123"); err == nil {
t.Fatal("ambiguous bare username should fail")
}
// 歧义时完整邮箱仍可用
if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil {
t.Fatalf("full email should still work: %v", err)
}
}