Author SHA1 Message Date
dsh f237a3d9e4 chore: 移除误提交的 migrate 编译产物,加入 .gitignore 2026-08-19 11:25:03 -04:00
dsh 641efdf49d feat(db): 支持 MySQL 迁移(修复模型外键冲突 + 大文本类型 + 迁移工具)
- Attachment 模型移除 Message 关联:其 foreignKey 名 MessageID 与
  Message.MessageID 字符串字段冲突,GORM AutoMigrate 会生成错误外键
  (messages.message_id → attachments.id 且强转 bigint),MySQL 下建表
  直接失败(SQLite 因动态类型侥幸可用)
- Message.TextBody/HtmlBody 改 mediumtext:MySQL TEXT 仅 64KB,
  大 HTML 邮件会写入失败
- 新增 cmd/migrate:SQLite → MySQL 一次性迁移工具(GORM 模型读源、
  批量写目标、时间统一 UTC、ban_entries 零值时间转 NULL、逐表校验)
2026-08-19 11:24:57 -04:00
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
18 changed files with 731 additions and 51 deletions
+1
View File
@@ -40,3 +40,4 @@ win/srv/
# 临时测试文件
login_test.html
migrate
+215
View File
@@ -0,0 +1,215 @@
// 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})
// 按外键依赖顺序复制: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 ----
var states []db.MailboxState
if err := sdb.Order("user_id ASC, folder ASC").Find(&states).Error; err != nil {
log.Fatalf("读 mailbox_states: %v", err)
}
for i := range states {
states[i].CreatedAt = states[i].CreatedAt.UTC()
states[i].UpdatedAt = states[i].UpdatedAt.UTC()
}
if err := mw.Create(&states).Error; err != nil {
log.Fatalf("写 mailbox_states: %v", err)
}
log.Printf("mailbox_states: %d", len(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)))
check("mailbox_states", int64(len(states)))
log.Println("迁移完成 ✅")
}
+8
View File
@@ -39,6 +39,10 @@ type WebConfig struct {
// ProtocolLogKeepDays SMTP/IMAP/POP3 协议调用日志保留天数,
// 超过该天数的记录会被后台任务自动清理。
ProtocolLogKeepDays int `toml:"protocol_log_keep_days"`
// Timezone Web 界面显示时间所用的 IANA 时区(如 "Asia/Shanghai")。
// 为空时使用服务器本地时区。邮件日期在库中统一为 UTC 存储,
// 展示时按此配置转换。
Timezone string `toml:"timezone"`
}
// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。
@@ -212,6 +216,7 @@ func defaultConfig() *Config {
Addr: DefaultWebPort,
CookieSecure: true,
ProtocolLogKeepDays: DefaultProtocolLogKeepDays,
Timezone: DefaultTimezone,
},
SMTP: SMTPConfig{
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
@@ -281,6 +286,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
if cfg.Web.ProtocolLogKeepDays == 0 {
cfg.Web.ProtocolLogKeepDays = defaults.Web.ProtocolLogKeepDays
}
if cfg.Web.Timezone == "" {
cfg.Web.Timezone = defaults.Web.Timezone
}
if cfg.SMTP.Addr == "" {
cfg.SMTP.Addr = defaults.SMTP.Addr
}
+3
View File
@@ -39,6 +39,9 @@ const (
// DefaultProtocolLogKeepDays 是 SMTP/IMAP/POP3 协议调用日志的默认保留天数。
const DefaultProtocolLogKeepDays = 30
// DefaultTimezone 是 Web 界面显示时间的默认 IANA 时区(北京时间)。
const DefaultTimezone = "Asia/Shanghai"
// Outbound delivery concurrency defaults.
const (
// 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
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)
}
+22 -3
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: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"`
@@ -155,10 +155,11 @@ 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"`
@@ -170,3 +171,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"
}
+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 == "" {
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})
imapMsg.Uid = uint32(msg.ID)
imapMsg.Flags = flagsOf(msg.IsRead, msg.IsFlagged, deleted)
imapMsg.Flags = flagsOf(read, flagged, deleted)
return &backend.MessageUpdate{
Update: backend.NewUpdate(userEmail, mailbox),
Message: imapMsg,
@@ -145,7 +155,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 +163,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 +369,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 +384,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 +417,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 +589,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([
}
}
}
return results, nil
}
@@ -779,12 +800,30 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
if err != nil {
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 忙/锁等瞬时失败必须让客户端感知
// (返回 NO 触发重试),否则已读/星标会静默丢失。
var firstErr error
type change struct {
msg *db.Message
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
if uid {
match = seqset.Contains(uint32(dbMsg.ID))
@@ -795,55 +834,93 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
continue
}
flagSet := make(map[string]bool, len(flags))
for _, flag := range flags {
flagSet[flag] = true
}
applyFlag := func(flag string, enabled bool) {
c := change{msg: dbMsg, seq: uint32(i + 1)}
apply := func(flag string, enabled bool) {
switch flag {
case "\\Seen":
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
}
c.newRead, c.readSet = enabled, true
case "\\Flagged":
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
}
c.newFlagged, c.flaggedSet = enabled, true
case "\\Deleted":
if enabled {
m.deleted[dbMsg.ID] = true
} else {
delete(m.deleted, dbMsg.ID)
}
c.newDeleted, c.deletedSet = enabled, true
}
}
switch op {
case imap.SetFlags:
applyFlag("\\Seen", flagSet["\\Seen"])
applyFlag("\\Flagged", flagSet["\\Flagged"])
applyFlag("\\Deleted", flagSet["\\Deleted"])
apply("\\Seen", flagSet["\\Seen"])
apply("\\Flagged", flagSet["\\Flagged"])
apply("\\Deleted", flagSet["\\Deleted"])
case imap.AddFlags:
for flag := range flagSet {
applyFlag(flag, true)
apply(flag, true)
}
case imap.RemoveFlags:
for flag := range flagSet {
applyFlag(flag, false)
apply(flag, false)
}
}
changes = append(changes, c)
}
// 标志变化(已读/星标/删除)→ 推送给同用户其他客户端
// (重新读库取最新状态,\Deleted 取会话内状态)
fresh, err := m.stores.Mails.GetByID(dbMsg.ID)
if err != nil {
continue
// 批量持久化(已读/星标合并为单条 UPDATE ... IN
var readTrue, readFalse, flagTrue, flagFalse []uint
for _, c := range changes {
if c.readSet && c.newRead != c.msg.IsRead {
if c.newRead {
readTrue = append(readTrue, c.msg.ID)
} else {
readFalse = append(readFalse, c.msg.ID)
}
}
deleted := m.deleted != nil && m.deleted[dbMsg.ID]
pushUpdate(m.user.updates, buildFlagsUpdate(m.stores, m.user.email, m.name, fresh, deleted))
if c.flaggedSet && c.newFlagged != c.msg.IsFlagged {
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
+41
View File
@@ -14,6 +14,7 @@ import (
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend"
imapserver "github.com/emersion/go-imap/server"
)
@@ -65,6 +66,12 @@ func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
if update == nil {
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)
}
@@ -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:载荷(消息/序号)共享,
// 但 Username/Mailbox/Done channel 重置为独立实例。
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())
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)
+29
View File
@@ -28,6 +28,10 @@ type MailStore interface {
MarkRead(id uint) error
MarkReadState(id uint, read 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
Delete(id uint) error
CountUnread(userID uint, folder string) (int64, error)
@@ -52,6 +56,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
}
@@ -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
}
// 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.
func (s *mailStoreGorm) MoveToFolder(id uint, folder string) 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
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)
}
}
+59 -1
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
"strconv"
"path/filepath"
"strings"
"time"
@@ -105,6 +106,8 @@ func templateFuncs() template.FuncMap {
"truncate": truncate,
// shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD。
"shortDate": shortDate,
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
"localTime": localTime,
// 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,
// this year -> MM-DD, otherwise -> YYYY-MM-DD.
// 时间先按 Web 配置时区转换(库内为 UTC),"今天"判断也使用该时区。
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() {
return t.Format("15:04")
}
@@ -168,6 +173,48 @@ func shortDate(t time.Time) string {
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.
func avatarStyle(s string) string {
h := 0
@@ -184,6 +231,17 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
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)
engine := gin.New()
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-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</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>
{{if .message.CcAddr}}
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;">