fix(auth+store): 协议登录成功清零失败计数/支持裸用户名 + 邮件日期统一 UTC 排序 #12

Merged
kevin merged 3 commits from dsh/mailgo:fix/auth-reset-and-date-sort into main 2026-08-19 23:48:59 +08:00
7 changed files with 238 additions and 40 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 为串行)。
+104 -38
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,
@@ -790,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))
@@ -806,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 {
+22
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)
@@ -103,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
+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;">