diff --git a/config/config.go b/config/config.go index 9125626..428cefd 100644 --- a/config/config.go +++ b/config/config.go @@ -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 } diff --git a/config/defaults.go b/config/defaults.go index 5cefac8..aada774 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -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 为串行)。 diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index cd5f393..2f2deec 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -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, @@ -790,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)) @@ -806,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 diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index 74e73c1..daed02b 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -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 2177(IDLE)要求新邮件到达 + // 时服务器发送 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 { diff --git a/internal/store/mail_store.go b/internal/store/mail_store.go index 68d310c..e0442b5 100644 --- a/internal/store/mail_store.go +++ b/internal/store/mail_store.go @@ -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) @@ -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 } +// 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 diff --git a/internal/web/server.go b/internal/web/server.go index 4e57936..b1bcb92 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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()) diff --git a/internal/web/templates/view.html b/internal/web/templates/view.html index 7db3518..1d73b0b 100644 --- a/internal/web/templates/view.html +++ b/internal/web/templates/view.html @@ -38,7 +38,7 @@