Author SHA1 Message Date
dsh aa0437fb4f perf(imap): STORE 批量持久化 + 推送序号复用已加载列表,消除逐条写库与全量扫描
原实现每封匹配消息单独 UPDATE + GetByID + seqOf 全量扫描(含大
附件 raw_data),手机整批标记已读(60+ 封)时产生 60 次写 +
180 次全量读,连接被长时间占住,其他连接响应被推送洪泛阻塞。

优化:收集目标状态后合并为单条 UPDATE ... IN 批量写;推送更新
直接用已加载列表的序号与目标状态(buildFlagsUpdateAt),不再
重复查库。实测 19 封批量 STORE 服务器耗时 372µs。
2026-08-19 11:13:42 -04:00
dsh 9fef9da830 fix(imap): 新邮件推送补发 EXISTS 通知,修复 Apple Mail 等客户端需手动同步
问题:go-imap 更新通道只能表达 FETCH/EXPUNGE 类更新,新邮件到达时
客户端(IDLE 中)只收到裸 FETCH。不少客户端(如 Apple Mail)只认
RFC 2177 要求的 EXISTS 才会唤醒拉取,表现为'必须手动点击同步'。

修复:PushNewMessage 先向所有已选中该邮箱的匹配连接直接写入
'* N EXISTS'(Dovecot 同款行为),再广播 FETCH 更新。
实测 IDLE 客户端按序收到 '* 21 EXISTS' + '* 1 FETCH (UID ...)'。
2026-08-19 10:56:26 -04:00
dsh 9df17a7509 feat(web): Web 界面时间按配置时区显示(默认 Asia/Shanghai),邮件日期 UTC 存储后显示不受影响
- 新增 [web].timezone 配置(IANA 时区或固定偏移,默认 Asia/Shanghai)
- 新增 localTime 模板函数;shortDate 的"今天"判断也按展示时区
- view.html 邮件时间改用 localTime 渲染
2026-08-19 10:45:23 -04:00
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
16 changed files with 511 additions and 48 deletions
+8
View File
@@ -39,6 +39,10 @@ type WebConfig struct {
// ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数, // ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数,
// 超过该天数的记录会被后台任务自动清理。 // 超过该天数的记录会被后台任务自动清理。
ProtocolLogKeepDays int `toml:"protocol_log_keep_days"` ProtocolLogKeepDays int `toml:"protocol_log_keep_days"`
// Timezone Web 界面显示时间所用的 IANA 时区(如 "Asia/Shanghai")。
// 为空时使用服务器本地时区。邮件日期在库中统一为 UTC 存储,
// 展示时按此配置转换。
Timezone string `toml:"timezone"`
} }
// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。 // SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。
@@ -212,6 +216,7 @@ func defaultConfig() *Config {
Addr: DefaultWebPort, Addr: DefaultWebPort,
CookieSecure: true, CookieSecure: true,
ProtocolLogKeepDays: DefaultProtocolLogKeepDays, ProtocolLogKeepDays: DefaultProtocolLogKeepDays,
Timezone: DefaultTimezone,
}, },
SMTP: SMTPConfig{ SMTP: SMTPConfig{
Addr: fmt.Sprintf(":%d", DefaultSMTPPort), Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
@@ -281,6 +286,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
if cfg.Web.ProtocolLogKeepDays == 0 { if cfg.Web.ProtocolLogKeepDays == 0 {
cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays
} }
if cfg.Web.Timezone == "" {
cfg.Web.Timezone = defaults.Web.Timezone
}
if cfg.SMTP.Addr == "" { if cfg.SMTP.Addr == "" {
cfg.SMTP.Addr = defaults.SMTP.Addr cfg.SMTP.Addr = defaults.SMTP.Addr
} }
+3
View File
@@ -39,6 +39,9 @@ const (
// DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。 // DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。
const DefaultProtocolLogKeepDays = 30 const DefaultProtocolLogKeepDays = 30
// DefaultTimezone 是 Web 界面显示时间的默认 IANA 时区(北京时间)。
const DefaultTimezone = "Asia/Shanghai"
// Outbound delivery concurrency defaults. // Outbound delivery concurrency defaults.
const ( const (
// DefaultOutboundWorkers 并发投递 worker 数(0/1 为串行)。 // DefaultOutboundWorkers 并发投递 worker 数(0/1 为串行)。
+1 -1
View File
@@ -56,7 +56,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
} }
// Auto-migrate all models // 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) return nil, fmt.Errorf("数据库迁移失败: %w", err)
} }
+18
View File
@@ -170,3 +170,21 @@ type Attachment struct {
func (Attachment) TableName() string { func (Attachment) TableName() string {
return "attachments" 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"
}
+120 -43
View File
@@ -83,10 +83,20 @@ func buildFlagsUpdate(stores *store.Stores, userEmail, mailbox string, msg *db.M
if stores == nil || msg == nil || userEmail == "" || mailbox == "" { if stores == nil || msg == nil || userEmail == "" || mailbox == "" {
return nil return nil
} }
imapMsg := imap.NewMessage(seqOf(stores, msg.UserID, mailbox, msg.ID), return buildFlagsUpdateAt(seqOf(stores, msg.UserID, mailbox, msg.ID), userEmail, mailbox, msg, msg.IsRead, msg.IsFlagged, deleted)
}
// buildFlagsUpdateAt 与 buildFlagsUpdate 相同,但序号与已读/星标状态由
// 调用方直接提供(批量 STORE 路径已加载全量列表,避免逐条 GetByID +
// seqOf 全量扫描)。
func buildFlagsUpdateAt(seq uint32, userEmail, mailbox string, msg *db.Message, read, flagged, deleted bool) *backend.MessageUpdate {
if msg == nil || userEmail == "" || mailbox == "" {
return nil
}
imapMsg := imap.NewMessage(seq,
[]imap.FetchItem{imap.FetchUid, imap.FetchFlags}) []imap.FetchItem{imap.FetchUid, imap.FetchFlags})
imapMsg.Uid = uint32(msg.ID) imapMsg.Uid = uint32(msg.ID)
imapMsg.Flags = flagsOf(msg.IsRead, msg.IsFlagged, deleted) imapMsg.Flags = flagsOf(read, flagged, deleted)
return &backend.MessageUpdate{ return &backend.MessageUpdate{
Update: backend.NewUpdate(userEmail, mailbox), Update: backend.NewUpdate(userEmail, mailbox),
Message: imapMsg, Message: imapMsg,
@@ -145,7 +155,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
return nil, backend.ErrInvalidCredentials return nil, backend.ErrInvalidCredentials
} }
user, err := b.stores.Users.Authenticate(username, password) user, err := b.stores.Users.AuthenticateLogin(username, password)
if err != nil { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多") b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
@@ -153,6 +163,11 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
return nil, fmt.Errorf("invalid credentials: %w", err) return nil, fmt.Errorf("invalid credentials: %w", err)
} }
// 登录成功清零失败计数(与 Web 登录一致):否则协议客户端的失败计数
// 只增不减(如配置探测、输错密码、APP 用裸用户名重试等),累计触发
// 档位封禁,合法用户 IP 被反复误封。
b.stores.Bans.ResetFail(clientIP)
email := user.Username + "@" email := user.Username + "@"
domain, err := b.stores.Domains.GetByID(user.DomainID) domain, err := b.stores.Domains.GetByID(user.DomainID)
if err == nil { if err == nil {
@@ -354,7 +369,6 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
status.Messages = uint32(len(messages)) status.Messages = uint32(len(messages))
var unseenCount uint32 var unseenCount uint32
@@ -370,7 +384,16 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
return nil, err return nil, err
} }
status.UidNext = uint32(maxID + 1) 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 return status, nil
} }
@@ -394,7 +417,6 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F
if err != nil { if err != nil {
return err return err
} }
if len(dbMessages) == 0 { if len(dbMessages) == 0 {
return nil return nil
} }
@@ -567,7 +589,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([
} }
} }
} }
return results, nil return results, nil
} }
@@ -779,12 +800,30 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
if err != nil { if err != nil {
return err return err
} }
// 批量模式:先收集每封匹配消息的目标标志状态,再合并成少量 SQL
// 写入。此前逐条 UPDATE + 逐条 GetByID + 逐条 seqOf 全量扫描,
// 手机整批标记已读(60+ 封)时会产生 60 次写 + 180 次全量读
// (含大附件 raw_data),该连接命令循环被长时间占住,其他连接
// 的响应通道也被推送洪泛阻塞——表现为"卡在接收邮件"。
flagSet := make(map[string]bool, len(flags))
for _, flag := range flags {
flagSet[flag] = true
}
// 记录首个持久化错误:SQLite 忙/锁等瞬时失败必须让客户端感知 type change struct {
// (返回 NO 触发重试),否则已读/星标会静默丢失。 msg *db.Message
var firstErr error seq uint32
newRead bool
readSet bool
newFlagged bool
flaggedSet bool
newDeleted bool
deletedSet bool
}
var changes []change
for i, dbMsg := range dbMessages { for i := range dbMessages {
dbMsg := &dbMessages[i]
var match bool var match bool
if uid { if uid {
match = seqset.Contains(uint32(dbMsg.ID)) match = seqset.Contains(uint32(dbMsg.ID))
@@ -795,55 +834,93 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
continue continue
} }
flagSet := make(map[string]bool, len(flags)) c := change{msg: dbMsg, seq: uint32(i + 1)}
for _, flag := range flags { apply := func(flag string, enabled bool) {
flagSet[flag] = true
}
applyFlag := func(flag string, enabled bool) {
switch flag { switch flag {
case "\\Seen": case "\\Seen":
if err := m.stores.Mails.MarkReadState(dbMsg.ID, enabled); err != nil && firstErr == nil { c.newRead, c.readSet = enabled, true
log.Printf("IMAP: mark read state for msg %d failed: %v", dbMsg.ID, err)
firstErr = err
}
case "\\Flagged": case "\\Flagged":
if err := m.stores.Mails.MarkFlagged(dbMsg.ID, enabled); err != nil && firstErr == nil { c.newFlagged, c.flaggedSet = enabled, true
log.Printf("IMAP: mark flagged for msg %d failed: %v", dbMsg.ID, err)
firstErr = err
}
case "\\Deleted": case "\\Deleted":
if enabled { c.newDeleted, c.deletedSet = enabled, true
m.deleted[dbMsg.ID] = true
} else {
delete(m.deleted, dbMsg.ID)
}
} }
} }
switch op { switch op {
case imap.SetFlags: case imap.SetFlags:
applyFlag("\\Seen", flagSet["\\Seen"]) apply("\\Seen", flagSet["\\Seen"])
applyFlag("\\Flagged", flagSet["\\Flagged"]) apply("\\Flagged", flagSet["\\Flagged"])
applyFlag("\\Deleted", flagSet["\\Deleted"]) apply("\\Deleted", flagSet["\\Deleted"])
case imap.AddFlags: case imap.AddFlags:
for flag := range flagSet { for flag := range flagSet {
applyFlag(flag, true) apply(flag, true)
} }
case imap.RemoveFlags: case imap.RemoveFlags:
for flag := range flagSet { for flag := range flagSet {
applyFlag(flag, false) apply(flag, false)
} }
} }
changes = append(changes, c)
}
// 标志变化(已读/星标/删除)→ 推送给同用户其他客户端 // 批量持久化(已读/星标合并为单条 UPDATE ... IN
// (重新读库取最新状态,\Deleted 取会话内状态) var readTrue, readFalse, flagTrue, flagFalse []uint
fresh, err := m.stores.Mails.GetByID(dbMsg.ID) for _, c := range changes {
if err != nil { if c.readSet && c.newRead != c.msg.IsRead {
continue if c.newRead {
readTrue = append(readTrue, c.msg.ID)
} else {
readFalse = append(readFalse, c.msg.ID)
}
} }
deleted := m.deleted != nil && m.deleted[dbMsg.ID] if c.flaggedSet && c.newFlagged != c.msg.IsFlagged {
pushUpdate(m.user.updates, buildFlagsUpdate(m.stores, m.user.email, m.name, fresh, deleted)) if c.newFlagged {
flagTrue = append(flagTrue, c.msg.ID)
} else {
flagFalse = append(flagFalse, c.msg.ID)
}
}
}
// 记录首个持久化错误:SQLite 忙/锁等瞬时失败必须让客户端感知
// (返回 NO 触发重试),否则已读/星标会静默丢失。
var firstErr error
mark := func(err error) {
if err != nil && firstErr == nil {
firstErr = err
}
}
if len(readTrue) > 0 {
mark(m.stores.Mails.SetReadStates(readTrue, true))
}
if len(readFalse) > 0 {
mark(m.stores.Mails.SetReadStates(readFalse, false))
}
if len(flagTrue) > 0 {
mark(m.stores.Mails.SetFlaggedStates(flagTrue, true))
}
if len(flagFalse) > 0 {
mark(m.stores.Mails.SetFlaggedStates(flagFalse, false))
}
// 标志变化 → 推送给同用户其他客户端(状态取目标值,序号用已加载
// 列表的下标——与 ListMessages/Status 全链路一致,不再重复全量扫描)
for _, c := range changes {
if c.deletedSet {
if c.newDeleted {
m.deleted[c.msg.ID] = true
} else {
delete(m.deleted, c.msg.ID)
}
}
read := c.msg.IsRead
if c.readSet {
read = c.newRead
}
flagged := c.msg.IsFlagged
if c.flaggedSet {
flagged = c.newFlagged
}
deleted := m.deleted != nil && m.deleted[c.msg.ID]
pushUpdate(m.user.updates, buildFlagsUpdateAt(c.seq, m.user.email, m.name, c.msg, read, flagged, deleted))
} }
return firstErr return firstErr
+41
View File
@@ -14,6 +14,7 @@ import (
"mail_go/internal/store" "mail_go/internal/store"
"mail_go/internal/tlsutil" "mail_go/internal/tlsutil"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/backend"
imapserver "github.com/emersion/go-imap/server" imapserver "github.com/emersion/go-imap/server"
) )
@@ -65,6 +66,12 @@ func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
if update == nil { if update == nil {
return return
} }
// 先发 EXISTS 通知再发 FETCH 更新:RFC 2177IDLE)要求新邮件到达
// 时服务器发送 EXISTS,不少客户端(如 Apple Mail)只认 EXISTS 才会
// 唤醒并主动拉取,仅裸 FETCH 更新会被忽略(表现为"必须手动同步")。
if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, "INBOX"); err == nil {
s.pushExists(userEmail, "INBOX", uint32(count))
}
s.broadcastUpdate(update, userEmail, msg.ID) s.broadcastUpdate(update, userEmail, msg.ID)
} }
@@ -112,6 +119,40 @@ func (s *IMAPServer) broadcastUpdate(update backend.Update, userEmail string, ms
} }
} }
// pushExists 向所有监听器中「已登录该用户且已选中该邮箱」的连接直接写入
// 未请求的 "* N EXISTS" 响应(绕过 go-imap 更新通道——其仅支持 FETCH/
// EXPUNGE 类更新,无法表达 EXISTS)。通道满时非阻塞丢弃,与广播一致。
func (s *IMAPServer) pushExists(userEmail, mailbox string, exists uint32) {
s.beMu.Lock()
srvs := append([]*imapserver.Server(nil), s.srvs...)
s.beMu.Unlock()
for _, srv := range srvs {
srv.ForEachConn(func(conn imapserver.Conn) {
ctx := conn.Context()
if ctx == nil || ctx.User == nil || ctx.Mailbox == nil {
return
}
if ctx.User.Username() != userEmail || ctx.Mailbox.Name() != mailbox {
return
}
select {
case ctx.Responses <- existsResponse(exists):
default:
log.Printf("IMAP: EXISTS 推送通道已满,丢弃 (user=%s mailbox=%s)", userEmail, mailbox)
}
})
}
}
// existsResponse 序列化为 "* N EXISTS\r\n"。
type existsResponse uint32
func (n existsResponse) WriteTo(w *imap.Writer) error {
_, err := fmt.Fprintf(w, "* %d EXISTS\r\n", uint32(n))
return err
}
// cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享, // cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享,
// 但 Username/Mailbox/Done channel 重置为独立实例。 // 但 Username/Mailbox/Done channel 重置为独立实例。
func cloneUpdate(u backend.Update) backend.Update { func cloneUpdate(u backend.Update) backend.Update {
+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()) 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 { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多") 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 return nil, nil, nil
} }
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.stores.Bans.ResetFail(clientIP)
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用 // 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
authUser.Username = user.Username authUser.Username = user.Username
+5 -1
View File
@@ -219,7 +219,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return smtp.ErrAuthFailed 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 { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.backend.server.stores.RecordAuthFailure( s.backend.server.stores.RecordAuthFailure(
@@ -232,6 +232,10 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return smtp.ErrAuthFailed return smtp.ErrAuthFailed
} }
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.backend.server.stores.Bans.ResetFail(s.clientIP)
domainName := user.Domain.Name domainName := user.Domain.Name
if domainName == "" { if domainName == "" {
domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID) domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID)
+29
View File
@@ -28,6 +28,10 @@ type MailStore interface {
MarkRead(id uint) error MarkRead(id uint) error
MarkReadState(id uint, read bool) error MarkReadState(id uint, read bool) error
MarkFlagged(id uint, flagged bool) error MarkFlagged(id uint, flagged bool) error
// SetReadStates 批量设置多封邮件的已读状态(单条 UPDATE ... IN)。
SetReadStates(ids []uint, read bool) error
// SetFlaggedStates 批量设置多封邮件的星标状态(单条 UPDATE ... IN)。
SetFlaggedStates(ids []uint, flagged bool) error
MoveToFolder(id uint, folder string) error MoveToFolder(id uint, folder string) error
Delete(id uint) error Delete(id uint) error
CountUnread(userID uint, folder string) (int64, error) CountUnread(userID uint, folder string) (int64, error)
@@ -52,6 +56,13 @@ func newMailStore(database *gorm.DB) MailStore {
// Create inserts a new message record. // Create inserts a new message record.
func (s *mailStoreGorm) Create(msg *db.Message) error { 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 return s.db.Create(msg).Error
} }
@@ -96,6 +107,24 @@ func (s *mailStoreGorm) MarkFlagged(id uint, flagged bool) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error
} }
// SetReadStates 批量设置多封邮件的已读状态。
// 客户端整批标记已读(手机同步后 STORE +FLAGS \Seen)时,逐条 UPDATE
// 会产生大量写事务并占住连接,这里合并为单条 SQL。
func (s *mailStoreGorm) SetReadStates(ids []uint, read bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_read", read).Error
}
// SetFlaggedStates 批量设置多封邮件的星标状态。
func (s *mailStoreGorm) SetFlaggedStates(ids []uint, flagged bool) error {
if len(ids) == 0 {
return nil
}
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_flagged", flagged).Error
}
// MoveToFolder changes the folder of a message. // MoveToFolder changes the folder of a message.
func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error { func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error {
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).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 Bans BanStore
Outbound OutboundStore Outbound OutboundStore
ProtocolLogs ProtocolLogStore ProtocolLogs ProtocolLogStore
MailboxState MailboxStateStore
} }
// NewStores creates a new Stores instance with all GORM-backed implementations. // NewStores creates a new Stores instance with all GORM-backed implementations.
@@ -27,6 +28,7 @@ func NewStores(database *gorm.DB) *Stores {
Bans: newBanStore(database), Bans: newBanStore(database),
Outbound: newOutboundStore(database), Outbound: newOutboundStore(database),
ProtocolLogs: newProtocolLogStore(database), ProtocolLogs: newProtocolLogStore(database),
MailboxState: newMailboxStateStore(database),
} }
} }
@@ -37,3 +39,4 @@ var _ = db.Message{}
var _ = db.Attachment{} var _ = db.Attachment{}
var _ = db.BanEntry{} var _ = db.BanEntry{}
var _ = db.ProtocolLog{} var _ = db.ProtocolLog{}
var _ = db.MailboxState{}
+33
View File
@@ -16,6 +16,10 @@ type UserStore interface {
GetByUsername(username string, domainID uint) (*db.User, error) GetByUsername(username string, domainID uint) (*db.User, error)
GetByEmail(email string) (*db.User, error) GetByEmail(email string) (*db.User, error)
Authenticate(email, password 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 Update(user *db.User) error
Delete(id uint) error Delete(id uint) error
List(domainID uint, page, size int) ([]db.User, int64, 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 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. // Update saves changes to an existing user record.
func (s *userStoreGorm) Update(user *db.User) error { func (s *userStoreGorm) Update(user *db.User) error {
return s.db.Save(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)
}
}
+59 -1
View File
@@ -8,6 +8,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -105,6 +106,8 @@ func templateFuncs() template.FuncMap {
"truncate": truncate, "truncate": truncate,
// shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD。 // shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD。
"shortDate": shortDate, "shortDate": shortDate,
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
"localTime": localTime,
// avatarStyle 根据字符串哈希生成头像背景/前景色。 // avatarStyle 根据字符串哈希生成头像背景/前景色。
"avatarStyle": avatarStyle, "avatarStyle": avatarStyle,
} }
@@ -157,8 +160,10 @@ func truncate(s string, n int) string {
// shortDate formats a time like QQ Mail does: today -> HH:mm, // shortDate formats a time like QQ Mail does: today -> HH:mm,
// this year -> MM-DD, otherwise -> YYYY-MM-DD. // this year -> MM-DD, otherwise -> YYYY-MM-DD.
// 时间先按 Web 配置时区转换(库内为 UTC),"今天"判断也使用该时区。
func shortDate(t time.Time) string { func shortDate(t time.Time) string {
now := time.Now() t = inWebTZ(t)
now := time.Now().In(t.Location())
if t.Year() == now.Year() && t.YearDay() == now.YearDay() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04") return t.Format("15:04")
} }
@@ -168,6 +173,48 @@ func shortDate(t time.Time) string {
return t.Format("2006-01-02") return t.Format("2006-01-02")
} }
// webTZ 是 Web 界面显示时间使用的时区(默认 Asia/Shanghai)。
var webTZ = time.Local
// fixedTimezone 解析 "+08:00"/"UTC+8" 形式的固定偏移时区;解析失败返回 nil。
func fixedTimezone(s string) *time.Location {
s = strings.TrimSpace(s)
sign := 1
rest := s
if strings.HasPrefix(rest, "+") {
rest = rest[1:]
} else if strings.HasPrefix(rest, "-") {
sign = -1
rest = rest[1:]
}
if strings.HasPrefix(strings.ToUpper(rest), "UTC") {
rest = strings.TrimSpace(rest[3:])
}
parts := strings.SplitN(rest, ":", 2)
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || h < 0 || h > 23 {
return nil
}
m := 0
if len(parts) == 2 {
if m, err = strconv.Atoi(strings.TrimSpace(parts[1])); err != nil || m < 0 || m > 59 {
return nil
}
}
offset := sign * (h*3600 + m*60)
return time.FixedZone("UTC"+strconv.Itoa(offset/3600), offset)
}
// inWebTZ 把时间转换到 Web 展示时区。
func inWebTZ(t time.Time) time.Time {
return t.In(webTZ)
}
// localTime 是 localTime 模板函数的实现(转换到 Web 展示时区)。
func localTime(t time.Time) time.Time {
return t.In(webTZ)
}
// avatarStyle returns inline CSS colors derived from a string hash. // avatarStyle returns inline CSS colors derived from a string hash.
func avatarStyle(s string) string { func avatarStyle(s string) string {
h := 0 h := 0
@@ -184,6 +231,17 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
return nil, err return nil, err
} }
// Web 展示时区:邮件日期库内统一 UTC 存储,界面按配置时区显示。
if cfg.Timezone != "" {
if loc, err := time.LoadLocation(cfg.Timezone); err == nil {
webTZ = loc
} else if loc2 := fixedTimezone(cfg.Timezone); loc2 != nil {
webTZ = loc2
} else {
return nil, fmt.Errorf("无效的 Web 时区配置 %q: %v", cfg.Timezone, err)
}
}
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
engine := gin.New() engine := gin.New()
engine.Use(gin.Logger()) engine.Use(gin.Logger())
+1 -1
View File
@@ -38,7 +38,7 @@
<div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div> <div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div>
<div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div> <div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div>
</div> </div>
<span class="mail-date">{{.message.Date.Format "2006-01-02 15:04:05"}}</span> <span class="mail-date">{{(localTime .message.Date).Format "2006-01-02 15:04:05"}}</span>
</div> </div>
{{if .message.CcAddr}} {{if .message.CcAddr}}
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;"> <div class="mail-from-addr" style="margin:-8px 0 16px 48px;">