feat(web): Web 界面时间按配置时区显示(默认 Asia/Shanghai),邮件日期 UTC 存储后显示不受影响
- 新增 [web].timezone 配置(IANA 时区或固定偏移,默认 Asia/Shanghai) - 新增 localTime 模板函数;shortDate 的"今天"判断也按展示时区 - view.html 邮件时间改用 localTime 渲染
This commit is contained in:
@@ -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 为串行)。
|
||||||
|
|||||||
+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