diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index bf7475b..a26c1be 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -56,6 +56,14 @@ func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, return &MailHandler{stores: stores, storage: attStorage, outbound: ob} } +// folderCounts returns sidebar badge counts for the current user. +func (h *MailHandler) folderCounts(userID uint) (inboxUnread, draftsTotal, sentTotal int64) { + inboxUnread, _ = h.stores.Mails.CountUnread(userID, "INBOX") + draftsTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Drafts") + sentTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Sent") + return +} + // Inbox renders the inbox page showing all messages in the user's INBOX folder. func (h *MailHandler) Inbox(c *gin.Context) { userID := c.GetUint("userID") @@ -67,7 +75,7 @@ func (h *MailHandler) Inbox(c *gin.Context) { return } - unreadCount, _ := h.stores.Mails.CountUnread(userID, "INBOX") + inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) currentUser, _ := c.Get("currentUser") @@ -83,12 +91,14 @@ func (h *MailHandler) Inbox(c *gin.Context) { "currentUser": currentUser, "messages": messages, "total": total, - "unreadCount": unreadCount, "page": page, "pageSize": 20, "totalPages": totalPages, "folder": "INBOX", "activeFolder": "inbox", + "inboxUnread": inboxUnread, + "draftsTotal": draftsTotal, + "sentTotal": sentTotal, }) } @@ -123,12 +133,16 @@ func (h *MailHandler) View(c *gin.Context) { } currentUser, _ := c.Get("currentUser") + inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) c.HTML(200, "view", gin.H{ "currentUser": currentUser, "message": msg, "attachments": attachments, "activeFolder": resolveActiveFolder(msg.Folder), + "inboxUnread": inboxUnread, + "draftsTotal": draftsTotal, + "sentTotal": sentTotal, }) } @@ -146,6 +160,8 @@ func (h *MailHandler) Compose(c *gin.Context) { quotaBytes = user.QuotaBytes } + inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) + c.HTML(200, "compose", gin.H{ "currentUser": currentUser, "activeFolder": "compose", @@ -155,6 +171,9 @@ func (h *MailHandler) Compose(c *gin.Context) { "bodyContent": "", "usedBytes": usedBytes, "quotaBytes": quotaBytes, + "inboxUnread": inboxUnread, + "draftsTotal": draftsTotal, + "sentTotal": sentTotal, }) } @@ -495,6 +514,7 @@ func (h *MailHandler) Sent(c *gin.Context) { } currentUser, _ := c.Get("currentUser") + inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) totalPages := int(total) / 20 if int(total)%20 > 0 { @@ -513,6 +533,9 @@ func (h *MailHandler) Sent(c *gin.Context) { "totalPages": totalPages, "folder": "Sent", "activeFolder": "sent", + "inboxUnread": inboxUnread, + "draftsTotal": draftsTotal, + "sentTotal": sentTotal, }) } @@ -630,6 +653,7 @@ func (h *MailHandler) Drafts(c *gin.Context) { } currentUser, _ := c.Get("currentUser") + inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) totalPages := int(total) / 20 if int(total)%20 > 0 { @@ -648,17 +672,25 @@ func (h *MailHandler) Drafts(c *gin.Context) { "totalPages": totalPages, "folder": "Drafts", "activeFolder": "drafts", + "inboxUnread": inboxUnread, + "draftsTotal": draftsTotal, + "sentTotal": sentTotal, }) } // Settings renders the user settings page. func (h *MailHandler) Settings(c *gin.Context) { currentUser, _ := c.Get("currentUser") + userID := c.GetUint("userID") + inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID) c.HTML(200, "settings", gin.H{ "currentUser": currentUser, "activeFolder": "settings", "error": "", "success": "", + "inboxUnread": inboxUnread, + "draftsTotal": draftsTotal, + "sentTotal": sentTotal, }) } diff --git a/internal/web/render_test.go b/internal/web/render_test.go new file mode 100644 index 0000000..6f1b6c8 --- /dev/null +++ b/internal/web/render_test.go @@ -0,0 +1,92 @@ +package web + +// Temporary render test used to validate the rewritten frontend templates. +// Renders every page template with realistic dummy data and writes the +// output to /tmp/mailgo_preview for visual verification. + +import ( + "html/template" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "mail_go/internal/db" +) + +func TestRenderAllPages(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseGlob(filepath.Join(wd, "templates", "*.html"))) + template.Must(tmpl.ParseGlob(filepath.Join(wd, "templates", "admin", "*.html"))) + + now := time.Now() + user := &db.User{ + ID: 1, + Username: "admin", + IsAdmin: true, + Domain: db.Domain{Name: "lmve.net"}, + UsedBytes: 5 * 1024 * 1024, + QuotaBytes: 5 * 1024 * 1024 * 1024, + } + + messages := []db.Message{ + {ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= ", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署,本邮件为测试邮件。", Date: now, IsRead: false}, + {ID: 2, Folder: "INBOX", FromAddr: "alice@example.com", ToAddr: "admin@lmve.net", Subject: "Re: 项目进度同步", TextBody: "好的,我们下周一上午十点开会同步一下进度。", Date: now.Add(-3 * time.Hour), IsRead: true}, + {ID: 3, Folder: "INBOX", FromAddr: "=?UTF-8?B?6ZmI5rKz?= ", ToAddr: "admin@lmve.net", Subject: "服务器巡检报告(8 月)", TextBody: "本月巡检完成,磁盘使用率 62%,内存使用正常。", Date: now.Add(-48 * time.Hour), IsRead: false}, + {ID: 4, Folder: "INBOX", FromAddr: "bob@other.com", ToAddr: "admin@lmve.net", Subject: "Newsletter #42", TextBody: "这是本周的资讯摘要,共 5 篇文章。", Date: now.Add(-10 * 24 * time.Hour), IsRead: true}, + {ID: 5, Folder: "INBOX", FromAddr: "=?UTF-8?B?6ZmI5rKz?= ", ToAddr: "admin@lmve.net", Subject: "DNS 记录更新", TextBody: "已按文档更新 SPF 与 DKIM 记录,请验证。", Date: now.Add(-100 * 24 * time.Hour), IsRead: true}, + } + + attachments := []db.Attachment{ + {ID: 1, FileName: "部署文档.pdf", FileSize: 1024 * 1024}, + {ID: 2, FileName: "logo.png", FileSize: 128 * 1024}, + } + + cases := []struct { + name string + data ginH + }{ + {"login", ginH{"error": ""}}, + {"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}}, + {"inbox", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "activeFolder": "inbox", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, + {"drafts", ginH{"currentUser": user, "messages": messages, "total": 1, "page": 1, "totalPages": 1, "activeFolder": "drafts", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, + {"sent", ginH{"currentUser": user, "messages": messages, "total": 3, "page": 1, "totalPages": 1, "activeFolder": "sent", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, + {"view", ginH{ + "currentUser": user, "activeFolder": "inbox", + "message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= ", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false}, + "attachments": attachments, "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3), + }}, + {"compose", ginH{ + "currentUser": user, "activeFolder": "compose", "error": "", + "to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "", + "usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024), + "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3), + }}, + {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, + {"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, + } + + outDir := os.Getenv("MAILGO_PREVIEW_DIR") + if outDir == "" { + outDir = filepath.Join(os.TempDir(), "mailgo_preview") + } + os.MkdirAll(outDir, 0755) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, tc.name, tc.data); err != nil { + t.Fatalf("render %s: %v", tc.name, err) + } + os.WriteFile(filepath.Join(outDir, tc.name+".html"), []byte(buf.String()), 0644) + t.Logf("%s -> %d bytes", tc.name, buf.Len()) + }) + } +} + +// ginH mimics gin.H so the test does not need the gin dependency surface. +type ginH map[string]interface{} diff --git a/internal/web/server.go b/internal/web/server.go index c76c6d8..c538f06 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strings" + "time" + "unicode/utf8" "mail_go/config" "mail_go/internal/mailutil" @@ -80,9 +82,88 @@ func templateFuncs() template.FuncMap { "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) *WebServer { diff --git a/internal/web/templates/banned.html b/internal/web/templates/banned.html index 10745fb..06ab231 100644 --- a/internal/web/templates/banned.html +++ b/internal/web/templates/banned.html @@ -7,17 +7,36 @@ 访问被禁止 - MailGo diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 5e892f4..d8a7a1d 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -1,66 +1,391 @@ {{define "styles"}} {{end}} {{define "navbar"}} {{if .currentUser}} - + {{end}} {{end}} + +{{define "sidebar"}} + +{{end}} diff --git a/internal/web/templates/compose.html b/internal/web/templates/compose.html index 30c12cd..10ce25a 100644 --- a/internal/web/templates/compose.html +++ b/internal/web/templates/compose.html @@ -4,63 +4,67 @@ - 撰写邮件 - MailGo + 写信 - MailGo {{template "styles" .}} - + {{template "navbar" .}} -
-
-