Files
mailgo/internal/web/handlers/mail.go
T
kevin ede85e0698 feat(imap): 新邮件实时推送(IDLE)+ 后台当前连接监控
- IMAP 推送:imapBackend 实现 backend.BackendUpdater,SMTP 本地投递
  与 Web 写信投递成功后 NotifyNewMessage,挂起 IDLE 的客户端即时收到
  新邮件 FETCH 通知(按用户名+INBOX 过滤广播,通道满非阻塞丢弃)
- 当前连接:新增 internal/connhub 连接注册中心,SMTP/IMAP/POP3 三协议
  注册/注销/用户名/TLS/活跃时间追踪;后台新增「当前连接」页
  (/admin/connections,统计卡片+连接表格,每 5 秒自动刷新)
- 新增测试:connhub 并发安全、推送内容/非阻塞/nil 安全、
  后台页面渲染;全量 -race 通过
2026-08-19 19:40:05 +08:00

874 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"encoding/base64"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"mail_go/internal/db"
"mail_go/internal/outbound"
"mail_go/internal/smtp_server"
"mail_go/internal/storage"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// pendingAttachment holds an uploaded attachment while the message is built.
type pendingAttachment struct {
filename string
contentType string
data []byte
}
// base64LineWrap encodes data as base64 wrapped at 76 columns (RFC 2045).
func base64LineWrap(data []byte) string {
enc := base64.StdEncoding.EncodeToString(data)
if len(enc) <= 76 {
return enc
}
var sb strings.Builder
for len(enc) > 76 {
sb.WriteString(enc[:76])
sb.WriteString("\r\n")
enc = enc[76:]
}
sb.WriteString(enc)
return sb.String()
}
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
type MailHandler struct {
stores *store.Stores
storage *storage.AttachmentStorage
outbound *outbound.Manager
// notify 本地投递成功通知(IMAP 新邮件推送),可空
notify smtp_server.NewMailNotify
}
// NewMailHandler creates a new MailHandler with the given stores, attachment
// storage and outbound delivery manager.
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, notify smtp_server.NewMailNotify) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, notify: notify}
}
// 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")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err)
return
}
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "inbox", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "INBOX",
"activeFolder": "inbox",
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
})
}
// View renders the email detail page for a specific message.
func (h *MailHandler) View(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "邮件不存在")
return
}
// Verify the message belongs to the current user
if msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
// Load attachments
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
// Auto mark as read
if !msg.IsRead {
_ = h.stores.Mails.MarkRead(uint(id))
msg.IsRead = true
}
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,
})
}
// Compose renders the email composition page.
func (h *MailHandler) Compose(c *gin.Context) {
userID := c.GetUint("userID")
currentUser, _ := c.Get("currentUser")
// Get user quota info for display
user, _ := h.stores.Users.GetByID(userID)
var usedBytes int64
var quotaBytes int64
if user != nil {
usedBytes = user.UsedBytes
quotaBytes = user.QuotaBytes
}
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
c.HTML(200, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "",
"to": c.Query("to"),
"subject": c.Query("subject"),
"bodyContent": "",
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
})
}
// DoSend processes the email composition form, sends the email via SMTP,
// and stores the message record.
func (h *MailHandler) DoSend(c *gin.Context) {
userID := c.GetUint("userID")
currentUserVal, _ := c.Get("currentUser")
currentUser := currentUserVal.(*db.User)
to := c.PostForm("to")
subject := c.PostForm("subject")
body := c.PostForm("body")
htmlBody := c.PostForm("html_body")
cc := c.PostForm("cc")
if to == "" {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "请输入收件人",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
// Handle attachments and check quota
form, multipartErr := c.MultipartForm()
attachments := make([]pendingAttachment, 0)
if multipartErr == nil {
files := form.File["attachments"]
if len(files) > 0 {
// 原子预扣附件配额(单条 SQLused_bytes + n <= quota_bytes 才生效),
// 防止并发提交绕过配额检查(TOCTOU)。后续保存失败会补偿回退。
var totalNewSize int64
for _, file := range files {
totalNewSize += file.Size
}
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
if err != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "配额检查失败,请稍后重试",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
if !reserved {
user, _ := h.stores.Users.GetByID(userID)
usedBytes, quotaBytes := currentUser.UsedBytes, currentUser.QuotaBytes
if user != nil {
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
}
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
})
return
}
// Read all attachment files into memory once (used for both the
// MIME message body and the stored attachment records).
// 读取失败的文件回退已预扣的配额。
for _, file := range files {
f, err := file.Open()
if err != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
continue
}
buf, readErr := io.ReadAll(f)
f.Close()
if readErr != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
continue
}
// Determine content type from extension
contentType := "application/octet-stream"
ext := strings.ToLower(filepath.Ext(file.Filename))
if ct, ok := mimeTypes[ext]; ok {
contentType = ct
}
attachments = append(attachments, pendingAttachment{
filename: file.Filename,
contentType: contentType,
data: buf,
})
}
}
}
// Build the email content
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
messageID, rawMessage := buildOutgoingMessage(fromAddr, to, cc, subject, body, htmlBody, attachments)
now := time.Now()
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
localUsers := make([]*db.User, 0, len(allRecipients))
var externalRecipients []string
for _, rcpt := range allRecipients {
user, err := h.stores.Users.GetByEmail(rcpt)
if err != nil {
externalRecipients = append(externalRecipients, rcpt)
continue
}
localUsers = append(localUsers, user)
}
// Queue external recipients for outbound delivery first, so that
// failures (rate limit, invalid address, disabled outbound) abort
// before any local copies are created.
if len(externalRecipients) > 0 {
ob := h.outbound
if ob == nil || !ob.Enabled() {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "外部投递未启用",
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
}
}
for _, rcptUser := range localUsers {
inboxMsg := &db.Message{
UserID: rcptUser.ID,
MessageID: messageID,
Folder: "INBOX",
FromAddr: fromAddr,
ToAddr: to,
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: rawMessage,
Date: now,
IsRead: false,
}
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("投递邮件失败: %v", createErr),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
if h.notify != nil {
h.notify(rcptUser.Username+"@"+rcptUser.Domain.Name, inboxMsg)
}
}
// Save to Sent folder
msg := &db.Message{
UserID: userID,
MessageID: messageID,
Folder: "Sent",
FromAddr: fromAddr,
ToAddr: to,
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: rawMessage,
Date: now,
IsRead: true,
}
if createErr := h.stores.Mails.Create(msg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("保存邮件失败: %v", createErr),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
// Save attachment records linked to the Sent copy (bytes were already
// read during message construction). 配额已在前面原子预扣,
// 保存/落库失败的附件需要补偿回退。
for _, att := range attachments {
relPath, err := h.storage.Save(att.filename, att.data)
if err != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -int64(len(att.data)))
continue
}
attRecord := &db.Attachment{
MessageID: msg.ID,
FileName: att.filename,
FilePath: relPath,
ContentType: att.contentType,
FileSize: int64(len(att.data)),
}
if err := h.stores.Attachments.Create(attRecord); err != nil {
_ = h.stores.Users.UpdateUsedBytes(userID, -attRecord.FileSize)
continue
}
}
c.Redirect(http.StatusFound, "/sent")
}
func parseAddressInput(input string) []string {
parts := strings.FieldsFunc(input, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
addresses := make([]string, 0, len(parts))
for _, part := range parts {
addr := strings.TrimSpace(part)
if addr != "" {
addresses = append(addresses, addr)
}
}
return addresses
}
// sanitizeHeaderField removes CR/LF/NUL from a value destined for an RFC 5322
// message header, preventing header injection (e.g. smuggling a Bcc or
// Reply-To header via a crafted subject or address list).
func sanitizeHeaderField(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\x00", "")
return s
}
// encodeSubject prepares a subject for safe inclusion as a message header:
// header injection characters are stripped and non-ASCII content is encoded
// per RFC 2047.
func encodeSubject(s string) string {
return mime.QEncoding.Encode("utf-8", sanitizeHeaderField(s))
}
// formatContentDisposition builds a Content-Disposition header value for the
// given filename, quoting/encoding it per RFC 2183/2231 (also neutralizes
// CR/LF injection through crafted filenames).
func formatContentDisposition(filename string) string {
return mime.FormatMediaType("attachment", map[string]string{"filename": filename})
}
// buildOutgoingMessage constructs the raw RFC 5322 message for the web
// compose form and returns its Message-ID. All header values derived from
// user input are sanitized to prevent CRLF header injection.
func buildOutgoingMessage(from, to, cc, subject, body, htmlBody string, attachments []pendingAttachment) (messageID, raw string) {
now := time.Now()
messageID = fmt.Sprintf("<%s@mail_go>", uuid.New().String())
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderField(from)))
sb.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderField(to)))
if cc != "" {
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", sanitizeHeaderField(cc)))
}
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", encodeSubject(subject)))
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
sb.WriteString("MIME-Version: 1.0\r\n")
// Attachments are wrapped in an outer multipart/mixed container.
outerBoundary := ""
hasAttachments := len(attachments) > 0
if hasAttachments {
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
}
// Build message body with multipart/alternative if HTML is present
if htmlBody != "" {
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
sb.WriteString(body)
sb.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
sb.WriteString(htmlBody)
sb.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
} else {
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
sb.WriteString("\r\n")
sb.WriteString(body)
sb.WriteString("\r\n")
}
// Append attachment parts to the multipart/mixed container.
for _, att := range attachments {
contentType := mime.FormatMediaType(att.contentType, map[string]string{"name": att.filename})
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
sb.WriteString(fmt.Sprintf("Content-Type: %s\r\n", contentType))
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
sb.WriteString(fmt.Sprintf("Content-Disposition: %s\r\n\r\n", formatContentDisposition(att.filename)))
sb.WriteString(base64LineWrap(att.data))
sb.WriteString("\r\n")
}
if hasAttachments {
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
}
return messageID, sb.String()
}
// mimeTypes maps common file extensions to MIME types.
var mimeTypes = map[string]string{
".txt": "text/plain",
".html": "text/html",
".htm": "text/html",
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".zip": "application/zip",
".rar": "application/x-rar-compressed",
".csv": "text/csv",
}
// Sent renders the sent mail folder page.
func (h *MailHandler) Sent(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "sent", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": "Sent",
"activeFolder": "sent",
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
})
}
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
// 调用方应回退到默认路径。
func safeRedirectPath(referer string) string {
if referer == "" || !strings.HasPrefix(referer, "/") || strings.HasPrefix(referer, "//") {
return ""
}
return referer
}
// Delete removes a message by ID after verifying ownership.
func (h *MailHandler) Delete(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
// Delete attachments on disk and in DB, and decrease UsedBytes
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
for _, att := range attachments {
_ = h.storage.Delete(att.FilePath)
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
}
_ = h.stores.Attachments.DeleteByMessage(uint(id))
_ = h.stores.Mails.Delete(uint(id))
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/inbox"
}
c.Redirect(http.StatusFound, referer)
}
// MarkRead marks a message as read.
func (h *MailHandler) MarkRead(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
_ = h.stores.Mails.MarkRead(uint(id))
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/inbox"
}
c.Redirect(http.StatusFound, referer)
}
// DownloadAttachment serves an attachment file for download.
func (h *MailHandler) DownloadAttachment(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的附件ID")
return
}
att, err := h.stores.Attachments.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "附件不存在")
return
}
// Verify the message belongs to the current user
msg, err := h.stores.Mails.GetByID(att.MessageID)
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
return
}
data, err := h.storage.Read(att.FilePath)
if err != nil {
c.String(http.StatusInternalServerError, "读取附件失败")
return
}
c.Header("Content-Disposition", formatContentDisposition(att.FileName))
c.Data(http.StatusOK, att.ContentType, data)
}
// getPageParam extracts and validates a page query parameter.
// Returns defaultVal if the parameter is missing or invalid.
func getPageParam(c *gin.Context, key string, defaultVal int) int {
pageStr := c.Query(key)
if pageStr == "" {
return defaultVal
}
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
return defaultVal
}
return page
}
// Drafts renders the drafts folder page.
func (h *MailHandler) Drafts(c *gin.Context) {
userID := c.GetUint("userID")
page := getPageParam(c, "page", 1)
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "drafts", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"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": "",
"mustChange": c.Query("force") == "1",
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
})
}
// UpdateSettings handles the password change form.
func (h *MailHandler) UpdateSettings(c *gin.Context) {
userID := c.GetUint("userID")
currentUserVal, _ := c.Get("currentUser")
currentUser := currentUserVal.(*db.User)
oldPassword := c.PostForm("old_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
c.HTML(http.StatusBadRequest, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "当前密码不正确",
"success": "",
})
return
}
if newPassword == "" {
c.HTML(http.StatusBadRequest, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "新密码不能为空",
"success": "",
})
return
}
if newPassword != confirmPassword {
c.HTML(http.StatusBadRequest, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "两次输入的密码不一致",
"success": "",
})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusInternalServerError, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "密码加密失败",
"success": "",
})
return
}
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
c.HTML(http.StatusInternalServerError, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "密码更新失败",
"success": "",
})
return
}
c.HTML(http.StatusOK, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "",
"success": "密码修改成功",
})
}
// resolveActiveFolder maps a folder name to a sidebar active state key.
func resolveActiveFolder(folder string) string {
switch folder {
case "INBOX":
return "inbox"
case "Sent":
return "sent"
case "Drafts":
return "drafts"
default:
return folder
}
}
// formatBytes converts a file size in bytes to a human-readable string.
// This is a handler-level utility that reuses the web package function.
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])
}