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
10 changed files with 458 additions and 43 deletions
+1
View File
@@ -40,3 +40,4 @@ win/srv/
# 临时测试文件 # 临时测试文件
login_test.html 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 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 为串行)。
+4 -3
View File
@@ -60,8 +60,8 @@ type Message struct {
ToAddr string `gorm:"size:2048;not null" json:"to_addr"` ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
CcAddr string `gorm:"size:2048" json:"cc_addr"` CcAddr string `gorm:"size:2048" json:"cc_addr"`
Subject string `gorm:"size:1024" json:"subject"` Subject string `gorm:"size:1024" json:"subject"`
TextBody string `gorm:"type:text" json:"text_body"` TextBody string `gorm:"type:mediumtext" json:"text_body"`
HtmlBody string `gorm:"type:text" json:"html_body"` HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
RawData string `gorm:"type:mediumtext" json:"raw_data"` RawData string `gorm:"type:mediumtext" json:"raw_data"`
IsRead bool `gorm:"default:false" json:"is_read"` IsRead bool `gorm:"default:false" json:"is_read"`
IsFlagged bool `gorm:"default:false" json:"is_flagged"` 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. // Attachment represents a file attached to an email message.
// 注意:不声明 Message 关联(避免 GORM 外键名 MessageID 与
// Message.MessageID 字符串字段冲突,导致 AutoMigrate 生成错误外键)。
type Attachment struct { type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
MessageID uint `gorm:"index;not null" json:"message_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"` FileName string `gorm:"size:255;not null" json:"file_name"`
FilePath string `gorm:"size:512;not null" json:"file_path"` FilePath string `gorm:"size:512;not null" json:"file_path"`
ContentType string `gorm:"size:128" json:"content_type"` ContentType string `gorm:"size:128" json:"content_type"`
+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;">