- base.html: 全新设计系统(顶部导航栏 + 左侧文件夹栏 + 内容区三栏布局, 蓝/橙主题色),兼容管理后台原有组件类 - inbox/drafts/sent: 邮件列表页重写,支持全选、批量删除、实时搜索过滤、 未读标记、头像圆标、QQ 式短日期、悬停行内删除、分页 - view: 邮件阅读页重写(返回/回复/删除工具条、发件人卡片、附件区) - compose: 写信页重写(发送/附件/取消、字段行、附件 chips、配额进度条) - settings/login/banned: 设置页(账号信息+配额条)、登录页、封禁页重写 - server.go: 新增模板函数 mailName/mailEmail/initial/truncate/shortDate/avatarStyle - mail.go: 侧栏文件夹计数(收件箱未读红标、草稿/已发送数量) - 新增 render_test.go 模板渲染回归测试
304 lines
9.5 KiB
Go
304 lines
9.5 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"mail_go/config"
|
|
"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
|
|
}
|
|
|
|
// 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 int) int { return a / b },
|
|
"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)
|
|
},
|
|
"safeHTML": func(s string) template.HTML {
|
|
return template.HTML(s)
|
|
},
|
|
"safeJS": func(s string) template.JS {
|
|
return template.JS(s)
|
|
},
|
|
"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 按 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) *WebServer {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
engine := gin.New()
|
|
engine.Use(gin.Logger())
|
|
engine.Use(gin.Recovery())
|
|
|
|
// Session store (cookie-based)
|
|
cookieStore := cookie.NewStore([]byte("mail-go-secret-key-change-in-production"))
|
|
cookieStore.Options(sessions.Options{
|
|
HttpOnly: true,
|
|
SameSite: 3, // SameSiteLaxMode
|
|
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,
|
|
}
|
|
|
|
ws.registerRoutes()
|
|
return ws
|
|
}
|
|
|
|
// 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)
|
|
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound)
|
|
|
|
// Apply BanMiddleware globally before public routes
|
|
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
|
|
|
// 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.POST("/bans/cleanup", adminHandler.CleanupBans)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|