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/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 @@
{{mailName (decodeHeader .message.FromAddr)}}
{{mailEmail .message.FromAddr}}
- {{.message.Date.Format "2006-01-02 15:04:05"}} + {{(localTime .message.Date).Format "2006-01-02 15:04:05"}} {{if .message.CcAddr}}