Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f237a3d9e4 | ||
|
|
641efdf49d | ||
|
|
aa0437fb4f | ||
|
|
9fef9da830 | ||
|
|
9df17a7509 |
@@ -40,3 +40,4 @@ win/srv/
|
|||||||
|
|
||||||
# 临时测试文件
|
# 临时测试文件
|
||||||
login_test.html
|
login_test.html
|
||||||
|
migrate
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// migrate 一次性工具:把 SQLite 数据迁移到 MySQL(mailgo 库)。
|
||||||
|
// 用法: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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标:MySQL(InitDB 内含 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 → 其余
|
||||||
|
// 所有时间统一 UTC(MySQL 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_entries(expires_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("迁移完成 ✅")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 为串行)。
|
||||||
|
|||||||
@@ -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
@@ -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]
|
}
|
||||||
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
|
return firstErr
|
||||||
|
|||||||
@@ -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 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)
|
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 {
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
Reference in New Issue
Block a user