package web import ( "encoding/json" "fmt" "html/template" "math" "net" "net/http" "os" "path/filepath" "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 // 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 注入。 "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 " 中提取显示名;无显示名时退回邮箱地址。 "mailName": mailName, // mailEmail 从 "Name " 中提取邮箱地址部分。 "mailEmail": mailEmail, // initial 返回字符串的首字符(用于头像占位)。 "initial": initial, // truncate 折叠空白并截断到 n 个字符(用于列表摘要)。 "truncate": truncate, // shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD。 "shortDate": shortDate, // avatarStyle 根据字符串哈希生成头像背景/前景色。 "avatarStyle": avatarStyle, } } // 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]) + "…" } // shortDate formats a time like QQ Mail does: today -> HH:mm, // this year -> MM-DD, otherwise -> YYYY-MM-DD. func shortDate(t time.Time) string { now := time.Now() if t.Year() == now.Year() && t.YearDay() == now.YearDay() { return t.Format("15:04") } if t.Year() == now.Year() { return t.Format("01-02") } return t.Format("2006-01-02") } // 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 } 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) ws := &WebServer{ engine: engine, stores: stores, storage: attStorage, cfg: cfg, storageCfg: storageCfg, authCfg: authCfg, banCfg: banCfg, caddyDataDir: caddyCfg.DataDir, outbound: ob, hub: hub, 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, 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) // Apply BanMiddleware globally before public routes ws.engine.Use(middleware.BanMiddleware(ws.stores)) // Security headers on every response ws.engine.Use(middleware.SecurityHeaders()) // 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 auth.GET("/inbox", mailHandler.Inbox) auth.GET("/inbox/:id", mailHandler.View) auth.GET("/compose", mailHandler.Compose) auth.POST("/compose", mailHandler.DoSend) auth.GET("/drafts", mailHandler.Drafts) auth.GET("/drafts/:id", mailHandler.View) auth.GET("/sent", mailHandler.Sent) auth.GET("/sent/:id", mailHandler.View) auth.GET("/settings", mailHandler.Settings) auth.POST("/settings", mailHandler.UpdateSettings) auth.POST("/mail/delete/:id", mailHandler.Delete) auth.POST("/mail/read/:id", mailHandler.MarkRead) auth.GET("/attachment/:id", mailHandler.DownloadAttachment) } // 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) }