- 后台页面此前直接 Format 输出库内时间(服务器 UTC),比北京时间慢 8 小时 - 新增模板函数 time12/time12m:先按 Web 时区转换,输出「2026-08-20 下午 2:35:05」 - shortDate 改为 12 小时制且非今天的邮件也显示完整时间(今天「下午 2:35」, 今年「08-20 下午 2:35」,更早「2026-08-20 下午 2:35」) - 统一替换:admin/mails、admin/outbound 用 time12m;protocol-logs、 connections、bans、mail_view、banned、view 用 time12 - 时区修正:协议日志 from/to 筛选与「今日」统计边界按 Web 时区计算 (此前 time.Local 在服务器 UTC 时差 8 小时) - 邮件列表日期列自适应宽度适配更长格式;新增 timefmt_test.go 回归测试
479 lines
16 KiB
Go
479 lines
16 KiB
Go
package web
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io/fs"
|
||
"math"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"mail_go/config"
|
||
"mail_go/internal/connhub"
|
||
"mail_go/internal/imap_server"
|
||
"mail_go/internal/mailutil"
|
||
"mail_go/internal/outbound"
|
||
"mail_go/internal/storage"
|
||
"mail_go/internal/store"
|
||
"mail_go/internal/web/handlers"
|
||
"mail_go/internal/web/middleware"
|
||
|
||
"github.com/gin-contrib/sessions"
|
||
"github.com/gin-contrib/sessions/cookie"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// formatBytes converts a file size in bytes to a human-readable string.
|
||
func formatBytes(b int64) string {
|
||
const unit = 1024
|
||
if b < unit {
|
||
return fmt.Sprintf("%d B", b)
|
||
}
|
||
div, exp := int64(unit), 0
|
||
for n := b / unit; n >= unit; n /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||
}
|
||
|
||
// WebServer wraps the Gin engine and its dependencies.
|
||
type WebServer struct {
|
||
engine *gin.Engine
|
||
stores *store.Stores
|
||
storage *storage.AttachmentStorage
|
||
cfg config.WebConfig
|
||
storageCfg config.StorageConfig
|
||
authCfg config.AuthConfig
|
||
banCfg config.BanConfig
|
||
caddyDataDir string
|
||
outbound *outbound.Manager
|
||
hub *connhub.Hub
|
||
// staticFS 内嵌的静态资源(/static 路由),二进制自包含
|
||
staticFS http.FileSystem
|
||
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
|
||
pusher imap_server.Pusher
|
||
}
|
||
|
||
// templateFuncs returns custom template functions for rendering.
|
||
func templateFuncs() template.FuncMap {
|
||
return template.FuncMap{
|
||
"add": func(a, b int) int { return a + b },
|
||
"sub": func(a, b int) int { return a - b },
|
||
"mul": func(a, b int) int { return a * b },
|
||
"div": func(a, b int64) int64 { return a / b },
|
||
// durationSeconds 将 time.Duration 转为整秒(模板中无法做类型转换)。
|
||
"durationSeconds": func(d time.Duration) int64 { return int64(d / time.Second) },
|
||
"mod": func(a, b int) int { return a % b },
|
||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||
"seq": func(n int) []int {
|
||
result := make([]int, n)
|
||
for i := 0; i < n; i++ {
|
||
result[i] = i + 1
|
||
}
|
||
return result
|
||
},
|
||
"domainName": func(domainID uint, domains []interface{}) string {
|
||
return fmt.Sprintf("Domain #%d", domainID)
|
||
},
|
||
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
|
||
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
|
||
// < > &(\u003c 等),无法逃出 </script>,杜绝 script 注入。
|
||
"jsonify": func(v interface{}) template.JS {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
return template.JS("null")
|
||
}
|
||
return template.JS(b)
|
||
},
|
||
"formatBytes": func(b int64) string {
|
||
return formatBytes(b)
|
||
},
|
||
"decodeHeader": func(s string) string {
|
||
return mailutil.DecodeRFC2047(s)
|
||
},
|
||
// mailName 从 "Name <addr>" 中提取显示名;无显示名时退回邮箱地址。
|
||
"mailName": mailName,
|
||
// mailEmail 从 "Name <addr>" 中提取邮箱地址部分。
|
||
"mailEmail": mailEmail,
|
||
// initial 返回字符串的首字符(用于头像占位)。
|
||
"initial": initial,
|
||
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
|
||
"truncate": truncate,
|
||
// shortDate 邮件列表时间:统一 12 小时制(上午/下午)。
|
||
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
|
||
// 更早显示「2026-08-20 下午 2:35」。
|
||
"shortDate": shortDate,
|
||
// time12 完整时间(含秒),先转换为 Web 时区:
|
||
// 「2026-08-20 下午 2:35:05」。
|
||
"time12": time12,
|
||
// time12m 同上但不含秒。
|
||
"time12m": time12m,
|
||
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
|
||
"localTime": localTime,
|
||
// avatarStyle 根据字符串哈希生成头像背景/前景色。
|
||
"avatarStyle": avatarStyle,
|
||
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
|
||
"urlPath": url.PathEscape,
|
||
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
|
||
"folderLabel": func(name string) string {
|
||
switch name {
|
||
case "INBOX":
|
||
return "收件箱"
|
||
case "Sent":
|
||
return "已发送"
|
||
case "Drafts":
|
||
return "草稿箱"
|
||
case "Trash":
|
||
return "已删除"
|
||
default:
|
||
return name
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
// mailName extracts the display name from an RFC 5322 address.
|
||
func mailName(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if i := strings.IndexByte(s, '<'); i >= 0 {
|
||
name := strings.Trim(strings.TrimSpace(s[:i]), `"' `)
|
||
if name != "" {
|
||
return name
|
||
}
|
||
if j := strings.IndexByte(s, '>'); j > i {
|
||
return s[i+1 : j]
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
// mailEmail extracts the bare email address from an RFC 5322 address.
|
||
func mailEmail(s string) string {
|
||
if i := strings.IndexByte(s, '<'); i >= 0 {
|
||
if j := strings.IndexByte(s, '>'); j > i {
|
||
return s[i+1 : j]
|
||
}
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// initial returns the first rune of a string, upper-cased.
|
||
func initial(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return "?"
|
||
}
|
||
r, _ := utf8.DecodeRuneInString(s)
|
||
return strings.ToUpper(string(r))
|
||
}
|
||
|
||
// truncate collapses whitespace and cuts the string to n runes.
|
||
func truncate(s string, n int) string {
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
|
||
func periodOf(t time.Time) (string, int) {
|
||
period := "上午"
|
||
if t.Hour() >= 12 {
|
||
period = "下午"
|
||
}
|
||
h := t.Hour() % 12
|
||
if h == 0 {
|
||
h = 12
|
||
}
|
||
return period, h
|
||
}
|
||
|
||
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
|
||
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
|
||
// 更早 → 「2026-08-20 下午 2:35」。
|
||
func shortDate(t time.Time) string {
|
||
t = inWebTZ(t)
|
||
now := time.Now().In(t.Location())
|
||
period, h := periodOf(t)
|
||
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
|
||
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||
return clock
|
||
}
|
||
if t.Year() == now.Year() {
|
||
return fmt.Sprintf("%s %s", t.Format("01-02"), clock)
|
||
}
|
||
return fmt.Sprintf("%s %s", t.Format("2006-01-02"), clock)
|
||
}
|
||
|
||
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
|
||
// 「2026-08-20 下午 2:35:05」。
|
||
func time12(t time.Time) string {
|
||
t = inWebTZ(t)
|
||
period, h := periodOf(t)
|
||
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
|
||
}
|
||
|
||
// time12m 完整时间(12 小时制,不含秒)。
|
||
func time12m(t time.Time) string {
|
||
t = inWebTZ(t)
|
||
period, h := periodOf(t)
|
||
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
|
||
}
|
||
|
||
// 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
|
||
for _, r := range s {
|
||
h = (h*31 + int(r)) % 360
|
||
}
|
||
return fmt.Sprintf("background:hsl(%d,78%%,92%%);color:hsl(%d,72%%,36%%)", h, h)
|
||
}
|
||
|
||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||
// configures sessions, middleware, and registers all routes.
|
||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager, hub *connhub.Hub, pusher imap_server.Pusher) (*WebServer, error) {
|
||
if err := config.ValidateSecretKey(cfg.SecretKey); err != nil {
|
||
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())
|
||
engine.Use(gin.Recovery())
|
||
|
||
// 仅信任本机回环上的反向代理(Caddy/Nginx)。外部直连时
|
||
// X-Forwarded-For 不可信,防止伪造客户端 IP 绕过登录封禁或
|
||
// 恶意封禁他人 IP。gin 对 Unix socket 监听无条件信任转发头,
|
||
// 因此 socket 必须保持仅本机可达。
|
||
if err := engine.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil {
|
||
return nil, fmt.Errorf("设置可信代理失败: %w", err)
|
||
}
|
||
|
||
// Session store (cookie-based). The signing key comes from the config
|
||
// file (auto-generated random key) or the MAILGO_SECRET_KEY env var.
|
||
cookieStore := cookie.NewStore([]byte(cfg.SecretKey))
|
||
cookieStore.Options(sessions.Options{
|
||
HttpOnly: true,
|
||
SameSite: 3, // SameSiteStrictMode(比 Lax 更严格)
|
||
Secure: cfg.CookieSecure,
|
||
MaxAge: 86400,
|
||
Path: "/",
|
||
})
|
||
engine.Use(sessions.Sessions("mail_go_session", cookieStore))
|
||
|
||
// Load HTML templates with custom functions
|
||
// Note: Go's filepath.Glob doesn't support **, so we load in two passes
|
||
tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseGlob("internal/web/templates/*.html"))
|
||
template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html"))
|
||
engine.SetHTMLTemplate(tmpl)
|
||
|
||
// 内嵌静态资源根目录(/static 路由数据源)
|
||
staticSub, err := fs.Sub(staticFS, "static")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("加载内嵌静态资源失败: %w", err)
|
||
}
|
||
|
||
ws := &WebServer{
|
||
engine: engine,
|
||
stores: stores,
|
||
storage: attStorage,
|
||
cfg: cfg,
|
||
storageCfg: storageCfg,
|
||
authCfg: authCfg,
|
||
banCfg: banCfg,
|
||
caddyDataDir: caddyCfg.DataDir,
|
||
outbound: ob,
|
||
hub: hub,
|
||
staticFS: http.FS(staticSub),
|
||
pusher: pusher,
|
||
}
|
||
|
||
ws.registerRoutes()
|
||
return ws, nil
|
||
}
|
||
|
||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||
func (ws *WebServer) registerRoutes() {
|
||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, imap_server.NewMailboxService(ws.stores), ws.pusher)
|
||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub, webTZ)
|
||
|
||
// Apply BanMiddleware globally before public routes
|
||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||
// Security headers on every response
|
||
ws.engine.Use(middleware.SecurityHeaders())
|
||
|
||
// 静态资源(本地化的 Quill 编辑器等第三方前端库)。
|
||
// 内嵌于二进制:同源加载以满足 CSP script-src/style-src 'self',
|
||
// 且不依赖部署目录(install.sh 只复制二进制 + templates)。
|
||
ws.engine.StaticFS("/static", ws.staticFS)
|
||
|
||
// Public routes (no auth required)
|
||
ws.engine.GET("/login", authHandler.ShowLogin)
|
||
ws.engine.POST("/login", authHandler.DoLogin)
|
||
ws.engine.POST("/login/ldap", authHandler.LDAPLogin)
|
||
ws.engine.GET("/auth/oauth2", authHandler.OAuth2Start)
|
||
ws.engine.GET("/auth/oauth2/callback", authHandler.OAuth2Callback)
|
||
|
||
// Auth-protected routes
|
||
auth := ws.engine.Group("")
|
||
auth.Use(middleware.AuthMiddleware(ws.stores))
|
||
{
|
||
auth.POST("/logout", authHandler.DoLogout)
|
||
auth.GET("/", func(c *gin.Context) {
|
||
c.Redirect(302, "/inbox")
|
||
})
|
||
|
||
// Mail routes:通用文件夹页(文件夹目录与 IMAP LIST 同源)
|
||
auth.GET("/folder/:name", mailHandler.Folder)
|
||
auth.GET("/folder/:name/:id", mailHandler.View)
|
||
auth.POST("/folder/:name/empty", mailHandler.EmptyFolder)
|
||
auth.GET("/compose", mailHandler.Compose)
|
||
auth.POST("/compose", mailHandler.DoSend)
|
||
auth.GET("/settings", mailHandler.Settings)
|
||
auth.POST("/settings", mailHandler.UpdateSettings)
|
||
auth.POST("/mail/delete/:id", mailHandler.Delete)
|
||
auth.POST("/mail/restore/:id", mailHandler.Restore)
|
||
auth.POST("/mail/purge/:id", mailHandler.Purge)
|
||
auth.POST("/mail/read/:id", mailHandler.MarkRead)
|
||
auth.GET("/attachment/:id", mailHandler.DownloadAttachment)
|
||
|
||
// 旧路径兼容重定向(登录跳转、书签、外部链接仍指向 /inbox 等)
|
||
auth.GET("/inbox", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX") })
|
||
auth.GET("/inbox/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX/"+c.Param("id")) })
|
||
auth.GET("/sent", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent") })
|
||
auth.GET("/sent/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent/"+c.Param("id")) })
|
||
auth.GET("/drafts", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts") })
|
||
auth.GET("/drafts/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts/"+c.Param("id")) })
|
||
}
|
||
|
||
// Admin routes (auth + admin required)
|
||
admin := ws.engine.Group("/admin")
|
||
admin.Use(middleware.AuthMiddleware(ws.stores))
|
||
admin.Use(middleware.AdminMiddleware())
|
||
{
|
||
admin.GET("", adminHandler.Dashboard)
|
||
admin.GET("/", adminHandler.Dashboard)
|
||
admin.GET("/domains", adminHandler.ListDomains)
|
||
admin.GET("/domains/new", adminHandler.NewDomain)
|
||
admin.POST("/domains", adminHandler.CreateDomain)
|
||
admin.GET("/domains/:id/edit", adminHandler.EditDomain)
|
||
admin.POST("/domains/:id", adminHandler.UpdateDomain)
|
||
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
||
admin.POST("/domains/:id/fetch-caddy-cert", adminHandler.FetchCaddyCert)
|
||
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
||
admin.GET("/users", adminHandler.ListUsers)
|
||
admin.GET("/users/new", adminHandler.NewUser)
|
||
admin.POST("/users", adminHandler.CreateUser)
|
||
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
||
admin.POST("/users/:id", adminHandler.UpdateUser)
|
||
admin.GET("/mails", adminHandler.ListMails)
|
||
admin.GET("/mails/:id", adminHandler.AdminViewMail)
|
||
admin.GET("/attachment/:id", adminHandler.AdminDownloadAttachment)
|
||
admin.GET("/outbound", adminHandler.ListOutbound)
|
||
admin.POST("/outbound/:id/retry", adminHandler.RetryOutbound)
|
||
admin.POST("/outbound/:id/cancel", adminHandler.CancelOutbound)
|
||
admin.GET("/bans", adminHandler.ListBans)
|
||
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||
admin.GET("/protocol-logs", adminHandler.ListProtocolLogs)
|
||
admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs)
|
||
admin.GET("/connections", adminHandler.ListConnections)
|
||
admin.POST("/connections/:id/disconnect", adminHandler.DisconnectConnection)
|
||
}
|
||
}
|
||
|
||
// Handler returns the underlying Gin engine as an http.Handler, useful for
|
||
// integration tests and for embedding behind a reverse proxy.
|
||
func (ws *WebServer) Handler() http.Handler {
|
||
return ws.engine
|
||
}
|
||
|
||
// Start launches the HTTP server on the configured address.
|
||
// Supports both TCP (e.g. ":8080") and Unix socket (e.g. "/run/mail_go/web.sock").
|
||
func (ws *WebServer) Start() error {
|
||
addr := ws.cfg.Addr
|
||
|
||
// Unix socket: 地址以 / 开头
|
||
if strings.HasPrefix(addr, "/") {
|
||
// 清理旧的 socket 文件
|
||
os.Remove(addr)
|
||
|
||
listener, err := net.Listen("unix", addr)
|
||
if err != nil {
|
||
return fmt.Errorf("监听 Unix socket 失败 %s: %w", addr, err)
|
||
}
|
||
// 允许 nginx 等外部进程连接
|
||
os.Chmod(addr, 0666)
|
||
|
||
return ws.engine.RunListener(listener)
|
||
}
|
||
|
||
// TCP 端口
|
||
return ws.engine.Run(addr)
|
||
}
|