Files
mailgo/internal/web/handlers/mail.go
T
kevin dc164fdf66 feat(mailbox): 文件夹数据驱动,Web 通过 IMAP 共享服务层操作邮箱
- 新增 mailboxes 表与 MailboxStore(系统文件夹幂等创建,自定义文件夹 CRUD)
- 提取 MailboxService:IMAP 会话与 Web handler 共用,LIST 返回什么 Web 就显示什么
- IMAP 支持 CREATE/DELETE/RENAME/SUBSCRIBE(系统文件夹禁删改、非空禁删)
- Web 删除改为 IMAP 语义:移入 Trash,新增恢复/彻底删除/清空
- 新增通用 /folder/:name 页面与动态侧边栏,/inbox /sent /drafts 保留兼容重定向
2026-08-20 01:25:40 +08:00

875 lines
27 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"
"log"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/outbound"
"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
// svc 邮箱服务层(IMAP 层共用):文件夹目录与消息操作同源。
svc *imap_server.MailboxService
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
pusher imap_server.Pusher
}
// NewMailHandler creates a new MailHandler with the given stores, attachment
// storage, mailbox service and outbound delivery manager.
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, svc *imap_server.MailboxService, pusher imap_server.Pusher) *MailHandler {
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, svc: svc, pusher: pusher}
}
// foldersFor 返回当前用户的侧边栏文件夹列表(与 IMAP LIST 同源:
// IMAP 返回什么文件夹,Web 就显示什么)。
func (h *MailHandler) foldersFor(userID uint) []imap_server.FolderInfo {
infos, err := h.svc.List(userID)
if err != nil {
log.Printf("web: 加载文件夹列表失败 user=%d: %v", userID, err)
return nil
}
return infos
}
// userEmailOf 从 context 取当前用户完整邮箱(推送用),失败返回空串。
func userEmailOf(c *gin.Context) string {
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
return u.Username + "@" + u.Domain.Name
}
}
return ""
}
// seqOfFolder 返回消息在文件夹中的序号(1 基,与 IMAP 序号排序一致)。
func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32 {
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, folder)
if err != nil {
return 0
}
for i := range msgs {
if msgs[i].ID == msgID {
return uint32(i + 1)
}
}
return 0
}
// purgeMessages 永久删除邮件(含附件文件与配额回退)。
func (h *MailHandler) purgeMessages(userID uint, msgs []db.Message) {
ids := make([]uint, 0, len(msgs))
for i := range msgs {
attachments, _ := h.stores.Attachments.ListByMessage(msgs[i].ID)
for _, att := range attachments {
_ = h.storage.Delete(att.FilePath)
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
}
if err := h.stores.Attachments.DeleteByMessage(msgs[i].ID); err != nil {
log.Printf("web: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err)
}
ids = append(ids, msgs[i].ID)
}
if err := h.stores.Mails.DeleteMany(ids); err != nil {
log.Printf("web: 删除邮件失败: %v", err)
}
}
// Folder renders the generic mailbox page for any folder the IMAP layer
// exposes (INBOX / Sent / Drafts / Trash / custom mailboxes).
func (h *MailHandler) Folder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
return
}
page := getPageParam(c, "page", 1)
messages, total, err := h.svc.Messages(userID, name, page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
return
}
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
currentUser, _ := c.Get("currentUser")
c.HTML(200, "folder", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": name,
"activeFolder": name,
"isTrash": name == "Trash",
"folders": h.foldersFor(userID),
})
}
// 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 {
if err := h.stores.Mails.MarkRead(uint(id)); err != nil {
log.Printf("web: 标记已读失败 msg=%d: %v", id, err)
}
msg.IsRead = true
}
currentUser, _ := c.Get("currentUser")
c.HTML(200, "view", gin.H{
"currentUser": currentUser,
"message": msg,
"attachments": attachments,
"activeFolder": msg.Folder,
"inTrash": msg.Folder == "Trash",
"folders": h.foldersFor(userID),
})
}
// 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
}
c.HTML(200, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "",
"to": c.Query("to"),
"subject": c.Query("subject"),
"bodyContent": "",
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
"folders": h.foldersFor(userID),
})
}
// composeData builds the shared template context for the compose page.
func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
return gin.H{
"currentUser": user,
"activeFolder": "compose",
"error": errMsg,
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": body,
"usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes,
"folders": h.foldersFor(userID),
}
}
// 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", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody))
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", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody))
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,
"folders": h.foldersFor(userID),
})
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", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody))
return
}
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
return
}
for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
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", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
return
}
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
if h.pusher != nil {
h.pusher.PushNewMessage(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", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
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",
}
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
// 调用方应回退到默认路径。
func safeRedirectPath(referer string) string {
if referer == "" || !strings.HasPrefix(referer, "/") || strings.HasPrefix(referer, "//") {
return ""
}
return referer
}
// Delete 删除邮件(IMAP 语义):非 Trash 文件夹 → 移入 Trash
// 已在 Trash → 彻底删除。
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
}
userEmail := userEmailOf(c)
if msg.Folder == "Trash" {
// 垃圾箱中删除 = 彻底删除
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
h.purgeMessages(userID, []db.Message{*msg})
if h.pusher != nil && userEmail != "" {
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
}
} else {
// 其余文件夹删除 = 移入垃圾箱(与 IMAP MOVE 同源语义)
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "删除失败")
return
}
if h.pusher != nil && userEmail != "" {
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
h.pusher.PushNewMessage(userEmail, &db.Message{UserID: userID, Folder: "Trash"})
}
}
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/folder/" + msg.Folder
}
c.Redirect(http.StatusFound, referer)
}
// Restore 把垃圾箱中的邮件恢复到收件箱。
func (h *MailHandler) Restore(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
}
if msg.Folder != "Trash" {
c.Redirect(http.StatusFound, "/folder/"+msg.Folder)
return
}
seq := h.seqOfFolder(userID, "Trash", msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "恢复失败")
return
}
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, "Trash", []uint32{seq})
h.pusher.PushNewMessage(email, &db.Message{UserID: userID, Folder: "INBOX"})
}
}
c.Redirect(http.StatusFound, "/folder/Trash")
}
// Purge 彻底删除一封邮件(任意文件夹)。
func (h *MailHandler) Purge(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
}
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
h.purgeMessages(userID, []db.Message{*msg})
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, msg.Folder, []uint32{seq})
}
}
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/folder/" + msg.Folder
}
c.Redirect(http.StatusFound, referer)
}
// EmptyFolder 清空文件夹(永久删除其中全部邮件)。
func (h *MailHandler) EmptyFolder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
return
}
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err)
return
}
seqs := make([]uint32, 0, len(msgs))
for i := range msgs {
seqs = append(seqs, uint32(i+1))
}
h.purgeMessages(userID, msgs)
if h.pusher != nil {
if email := userEmailOf(c); email != "" {
h.pusher.PushExpunged(email, name, seqs)
}
}
c.Redirect(http.StatusFound, "/folder/"+name)
}
// 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))
// 已读变化 → 推送给该用户的其他 IMAP 客户端
if h.pusher != nil {
msg.IsRead = true
userEmail := ""
if cu, ok := c.Get("currentUser"); ok {
if u, ok := cu.(*db.User); ok {
userEmail = u.Username + "@" + u.Domain.Name
}
}
h.pusher.PushFlagsChanged(userEmail, msg.Folder, msg)
}
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
referer := safeRedirectPath(c.GetHeader("Referer"))
if referer == "" {
referer = "/folder/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
}
// Settings renders the user settings page.
func (h *MailHandler) Settings(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
userID := c.GetUint("userID")
c.HTML(200, "settings", gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "",
"success": "",
"mustChange": c.Query("force") == "1",
"folders": h.foldersFor(userID),
})
}
// settingsData builds the shared template context for the settings page.
func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H {
return gin.H{
"currentUser": user,
"activeFolder": "settings",
"error": errMsg,
"success": success,
"folders": h.foldersFor(userID),
}
}
// 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", h.settingsData(userID, currentUser, "当前密码不正确", ""))
return
}
if newPassword == "" {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", ""))
return
}
if newPassword != confirmPassword {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", ""))
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", ""))
return
}
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", ""))
return
}
c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功"))
}
// 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])
}