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 保留兼容重定向
This commit is contained in:
+236
-276
@@ -51,38 +51,88 @@ 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 and outbound delivery manager.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, pusher imap_server.Pusher) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, pusher: pusher}
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// foldersFor 返回当前用户的侧边栏文件夹列表(与 IMAP LIST 同源:
|
||||
// IMAP 返回什么文件夹,Web 就显示什么)。
|
||||
func (h *MailHandler) foldersFor(userID uint) []imap_server.FolderInfo {
|
||||
infos, err := h.svc.List(userID)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err)
|
||||
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)
|
||||
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
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 {
|
||||
@@ -92,18 +142,18 @@ func (h *MailHandler) Inbox(c *gin.Context) {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "inbox", gin.H{
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(200, "folder", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "INBOX",
|
||||
"activeFolder": "inbox",
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
"folder": name,
|
||||
"activeFolder": name,
|
||||
"isTrash": name == "Trash",
|
||||
"folders": h.foldersFor(userID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,16 +190,14 @@ 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,
|
||||
"activeFolder": msg.Folder,
|
||||
"inTrash": msg.Folder == "Trash",
|
||||
"folders": h.foldersFor(userID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,8 +215,6 @@ 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",
|
||||
@@ -178,12 +224,26 @@ func (h *MailHandler) Compose(c *gin.Context) {
|
||||
"bodyContent": "",
|
||||
"usedBytes": usedBytes,
|
||||
"quotaBytes": quotaBytes,
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
"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) {
|
||||
@@ -198,17 +258,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
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,
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -226,17 +276,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
}
|
||||
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,
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
if !reserved {
|
||||
@@ -255,6 +295,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": usedBytes,
|
||||
"quotaBytes": quotaBytes,
|
||||
"folders": h.foldersFor(userID),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -314,46 +355,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
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,
|
||||
})
|
||||
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", 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,
|
||||
})
|
||||
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", 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,
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -375,17 +386,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
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,
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||
@@ -411,17 +412,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,43 +563,6 @@ var mimeTypes = map[string]string{
|
||||
".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 仅接受同站相对路径(以 / 开头且非 //),
|
||||
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
|
||||
// 调用方应回退到默认路径。
|
||||
@@ -619,7 +573,8 @@ func safeRedirectPath(referer string) string {
|
||||
return referer
|
||||
}
|
||||
|
||||
// Delete removes a message by ID after verifying ownership.
|
||||
// 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)
|
||||
@@ -633,50 +588,128 @@ func (h *MailHandler) Delete(c *gin.Context) {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
userEmail := userEmailOf(c)
|
||||
|
||||
// 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)
|
||||
}
|
||||
if err := h.stores.Attachments.DeleteByMessage(uint(id)); err != nil {
|
||||
log.Printf("web: 删除附件记录失败 msg=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// 删除前计算消息在所属文件夹中的序号(用于 Expunge 推送)
|
||||
var seq uint32
|
||||
if msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, msg.Folder); err == nil {
|
||||
for i := range msgs {
|
||||
if msgs[i].ID == uint(id) {
|
||||
seq = uint32(i + 1)
|
||||
break
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
if err := h.stores.Mails.Delete(uint(id)); err != nil {
|
||||
log.Printf("web: 删除邮件失败 msg=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// 删除 → 推送给该用户的其他 IMAP 客户端
|
||||
if h.pusher != nil && seq > 0 {
|
||||
userEmail := ""
|
||||
if cu, ok := c.Get("currentUser"); ok {
|
||||
if u, ok := cu.(*db.User); ok {
|
||||
userEmail = u.Username + "@" + u.Domain.Name
|
||||
}
|
||||
} 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"})
|
||||
}
|
||||
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
|
||||
}
|
||||
|
||||
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
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")
|
||||
@@ -709,7 +742,7 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
|
||||
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
referer = "/folder/INBOX"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
@@ -760,60 +793,31 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
|
||||
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,
|
||||
"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")
|
||||
@@ -826,76 +830,32 @@ func (h *MailHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
// 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": "",
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", ""))
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword == "" {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "新密码不能为空",
|
||||
"success": "",
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", ""))
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "两次输入的密码不一致",
|
||||
"success": "",
|
||||
})
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码加密失败",
|
||||
"success": "",
|
||||
})
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码更新失败",
|
||||
"success": "",
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", ""))
|
||||
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
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功"))
|
||||
}
|
||||
|
||||
// formatBytes converts a file size in bytes to a human-readable string.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package handlers
|
||||
|
||||
// Web 删除语义回归测试:删除=移入垃圾箱(IMAP 语义)、垃圾箱中删除=彻底
|
||||
// 删除、恢复=回到收件箱、清空=永久删除。文件夹操作全部经由 MailboxService
|
||||
// (与 IMAP 会话共用同一份实现)。
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newMailTestHandler(t *testing.T) (*MailHandler, *store.Stores) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewMailHandler(stores, nil, nil, imap_server.NewMailboxService(stores), nil), stores
|
||||
}
|
||||
|
||||
// newMailTestRouter 注册删除/恢复/清空路由并注入认证上下文。
|
||||
func newMailTestRouter(h *MailHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// Folder 页渲染需要模板(与 oauth2 测试共用同一份测试函数表)
|
||||
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(1))
|
||||
c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}})
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/mail/delete/:id", h.Delete)
|
||||
r.POST("/mail/restore/:id", h.Restore)
|
||||
r.POST("/mail/purge/:id", h.Purge)
|
||||
r.POST("/folder/:name/empty", h.EmptyFolder)
|
||||
r.GET("/folder/:name", h.Folder)
|
||||
return r
|
||||
}
|
||||
|
||||
func seedWebMsg(t *testing.T, stores *store.Stores, folder string) *db.Message {
|
||||
t.Helper()
|
||||
msg := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: folder,
|
||||
FromAddr: "sender@other.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "测试邮件",
|
||||
Date: time.Now(),
|
||||
}
|
||||
if err := stores.Mails.Create(msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func msgFolder(t *testing.T, stores *store.Stores, id uint) (string, bool) {
|
||||
t.Helper()
|
||||
msg, err := stores.Mails.GetByID(id)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return msg.Folder, true
|
||||
}
|
||||
|
||||
func TestWebDeleteMovesToTrash(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
msg := seedWebMsg(t, stores, "INBOX")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
// 收件箱删除 → 移入垃圾箱(非物理删除)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
|
||||
req.Header.Set("Referer", "/folder/INBOX")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("delete status = %d, want 302", w.Code)
|
||||
}
|
||||
folder, ok := msgFolder(t, stores, msg.ID)
|
||||
if !ok || folder != "Trash" {
|
||||
t.Fatalf("deleted msg folder = %q, want Trash", folder)
|
||||
}
|
||||
|
||||
// 垃圾箱中再删除 → 彻底删除
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
|
||||
req2.Header.Set("Referer", "/folder/Trash")
|
||||
r.ServeHTTP(w2, req2)
|
||||
if _, ok := msgFolder(t, stores, msg.ID); ok {
|
||||
t.Fatal("trash delete should purge the message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRestoreToInbox(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
msg := seedWebMsg(t, stores, "Trash")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/mail/restore/"+itoa(msg.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("restore status = %d, want 302", w.Code)
|
||||
}
|
||||
folder, ok := msgFolder(t, stores, msg.ID)
|
||||
if !ok || folder != "INBOX" {
|
||||
t.Fatalf("restored msg folder = %q, want INBOX", folder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebEmptyFolderPurgesAll(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
seedWebMsg(t, stores, "Trash")
|
||||
seedWebMsg(t, stores, "Trash")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/folder/Trash/empty", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("empty status = %d, want 302", w.Code)
|
||||
}
|
||||
count, err := stores.Mails.CountByUserAndFolder(1, "Trash")
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("Trash count after empty = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFolderPageListsDynamicFolders(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
seedWebMsg(t, stores, "INBOX")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
// IMAP CREATE 语义创建的自定义文件夹(经同一 MailboxService)
|
||||
svc := imap_server.NewMailboxService(stores)
|
||||
if err := svc.Create(1, "工作"); err != nil {
|
||||
t.Fatalf("create custom mailbox: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/folder/工作", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("custom folder page status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// 不存在的文件夹 → 404
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/folder/不存在", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing folder status = %d, want 404", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n uint) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -46,7 +46,10 @@ func testTemplateFuncs() template.FuncMap {
|
||||
"initial": func(s string) string { return "?" },
|
||||
"truncate": func(s string, n int) string { return s },
|
||||
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
"localTime": func(t time.Time) time.Time { return t },
|
||||
"avatarStyle": func(s string) string { return "background:#eee;color:#333" },
|
||||
"urlPath": func(s string) string { return url.PathEscape(s) },
|
||||
"folderLabel": func(s string) string { return s },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
)
|
||||
|
||||
func TestRenderAllPages(t *testing.T) {
|
||||
@@ -46,27 +47,34 @@ func TestRenderAllPages(t *testing.T) {
|
||||
{ID: 2, FileName: "logo.png", FileSize: 128 * 1024},
|
||||
}
|
||||
|
||||
folders := []imap_server.FolderInfo{
|
||||
{Name: "INBOX", SpecialUse: "", Unseen: 2},
|
||||
{Name: "Sent", SpecialUse: "Sent", Total: 3},
|
||||
{Name: "Drafts", SpecialUse: "Drafts", Total: 1},
|
||||
{Name: "Trash", SpecialUse: "Trash", Total: 5},
|
||||
{Name: "工作", SpecialUse: "", Total: 4},
|
||||
}
|
||||
|
||||
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)}},
|
||||
{"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
|
||||
{"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
|
||||
{"view", ginH{
|
||||
"currentUser": user, "activeFolder": "inbox",
|
||||
"currentUser": user, "activeFolder": "INBOX",
|
||||
"message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false},
|
||||
"attachments": attachments, "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3),
|
||||
"attachments": attachments, "inTrash": false, "folders": folders,
|
||||
}},
|
||||
{"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),
|
||||
"folders": folders,
|
||||
}},
|
||||
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}},
|
||||
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
|
||||
{"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}},
|
||||
{"admin_bans", ginH{
|
||||
"currentUser": user, "activeFolder": "bans",
|
||||
|
||||
+33
-8
@@ -7,6 +7,7 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
@@ -110,6 +111,23 @@ func templateFuncs() template.FuncMap {
|
||||
"localTime": localTime,
|
||||
// avatarStyle 根据字符串哈希生成头像背景/前景色。
|
||||
"avatarStyle": avatarStyle,
|
||||
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
|
||||
"urlPath": url.PathEscape,
|
||||
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
|
||||
"folderLabel": func(name string) string {
|
||||
switch name {
|
||||
case "INBOX":
|
||||
return "收件箱"
|
||||
case "Sent":
|
||||
return "已发送"
|
||||
case "Drafts":
|
||||
return "草稿箱"
|
||||
case "Trash":
|
||||
return "已删除"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +312,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
// 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)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, imap_server.NewMailboxService(ws.stores), 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
|
||||
@@ -318,20 +336,27 @@ func (ws *WebServer) registerRoutes() {
|
||||
c.Redirect(302, "/inbox")
|
||||
})
|
||||
|
||||
// Mail routes
|
||||
auth.GET("/inbox", mailHandler.Inbox)
|
||||
auth.GET("/inbox/:id", mailHandler.View)
|
||||
// Mail routes:通用文件夹页(文件夹目录与 IMAP LIST 同源)
|
||||
auth.GET("/folder/:name", mailHandler.Folder)
|
||||
auth.GET("/folder/:name/:id", mailHandler.View)
|
||||
auth.POST("/folder/:name/empty", mailHandler.EmptyFolder)
|
||||
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/restore/:id", mailHandler.Restore)
|
||||
auth.POST("/mail/purge/:id", mailHandler.Purge)
|
||||
auth.POST("/mail/read/:id", mailHandler.MarkRead)
|
||||
auth.GET("/attachment/:id", mailHandler.DownloadAttachment)
|
||||
|
||||
// 旧路径兼容重定向(登录跳转、书签、外部链接仍指向 /inbox 等)
|
||||
auth.GET("/inbox", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX") })
|
||||
auth.GET("/inbox/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX/"+c.Param("id")) })
|
||||
auth.GET("/sent", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent") })
|
||||
auth.GET("/sent/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent/"+c.Param("id")) })
|
||||
auth.GET("/drafts", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts") })
|
||||
auth.GET("/drafts/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts/"+c.Param("id")) })
|
||||
}
|
||||
|
||||
// Admin routes (auth + admin required)
|
||||
|
||||
@@ -41,7 +41,7 @@ func newTestStores(t *testing.T) *store.Stores {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.Mailbox{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return store.NewStores(gdb)
|
||||
@@ -122,7 +122,7 @@ func TestSessionSignedWithConfiguredSecretKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 合法会话可以访问收件箱
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie})
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -151,7 +151,7 @@ func TestLegacyHardcodedKeyCannotForgeSession(t *testing.T) {
|
||||
t.Fatalf("forge cookie: %v", err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -229,7 +229,7 @@ func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
|
||||
expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -254,7 +254,7 @@ func TestSessionWithinExpiryWorks(t *testing.T) {
|
||||
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
|
||||
@@ -353,11 +353,12 @@
|
||||
.folder {
|
||||
flex: 1; flex-direction: column; justify-content: center; gap: 2px;
|
||||
height: 46px; padding: 0; border-radius: 8px; font-size: 10.5px;
|
||||
position: relative;
|
||||
position: relative; order: 7; /* 自定义文件夹排在最后 */
|
||||
}
|
||||
.folder-nav .folder:nth-child(1) { order: 1; }
|
||||
.folder-nav .folder:nth-child(2) { order: 2; }
|
||||
.folder-nav .folder:nth-child(3) { order: 4; }
|
||||
.folder-nav .folder:nth-child(4) { order: 6; }
|
||||
.folder svg { width: 19px; height: 19px; }
|
||||
.folder.active::before { display: none; }
|
||||
.folder .badge {
|
||||
@@ -376,7 +377,7 @@
|
||||
.compose-btn svg { width: 20px; height: 20px; }
|
||||
.sidebar-footer {
|
||||
margin: 0; border: none; padding: 0;
|
||||
flex-direction: row; gap: 2px; order: 5;
|
||||
flex-direction: row; gap: 2px; order: 8;
|
||||
}
|
||||
.mail-main { padding-bottom: calc(58px + env(safe-area-inset-bottom)); }
|
||||
|
||||
@@ -463,21 +464,25 @@
|
||||
写信
|
||||
</a>
|
||||
<nav class="folder-nav">
|
||||
<a class="folder {{if eq .activeFolder `inbox`}}active{{end}}" href="/inbox">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
收件箱
|
||||
{{if .inboxUnread}}<span class="badge">{{.inboxUnread}}</span>{{end}}
|
||||
</a>
|
||||
<a class="folder {{if eq .activeFolder `drafts`}}active{{end}}" href="/drafts">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
草稿箱
|
||||
{{if .draftsTotal}}<span class="count">{{.draftsTotal}}</span>{{end}}
|
||||
</a>
|
||||
<a class="folder {{if eq .activeFolder `sent`}}active{{end}}" href="/sent">
|
||||
{{range .folders}}
|
||||
<a class="folder {{if eq $.activeFolder .Name}}active{{end}}" href="/folder/{{urlPath .Name}}">
|
||||
{{if eq .SpecialUse "Sent"}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
已发送
|
||||
{{if .sentTotal}}<span class="count">{{.sentTotal}}</span>{{end}}
|
||||
{{else if eq .SpecialUse "Drafts"}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
{{else if eq .SpecialUse "Trash"}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
{{else if eq .Name "INBOX"}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
{{else}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
{{end}}
|
||||
{{folderLabel .Name}}
|
||||
{{if eq .Name "INBOX"}}
|
||||
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
|
||||
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
{{if .currentUser.IsAdmin}}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{{define "drafts"}}
|
||||
{{define "folder"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>草稿箱 - MailGo</title>
|
||||
<title>{{folderLabel .folder}} - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
@@ -23,29 +23,46 @@
|
||||
</button>
|
||||
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
删除
|
||||
{{if .isTrash}}彻底删除{{else}}删除{{end}}
|
||||
</button>
|
||||
{{if .isTrash}}
|
||||
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
|
||||
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');">
|
||||
<button type="submit" class="tb-btn danger">清空文件夹</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<div class="toolbar-spacer"></div>
|
||||
<span class="page-info">共 {{.total}} 封</span>
|
||||
</div>
|
||||
|
||||
{{if not .messages}}
|
||||
<div class="mail-list">
|
||||
<div class="empty-tip"><span class="empty-icon">📝</span>草稿箱暂无邮件</div>
|
||||
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row" data-id="{{.ID}}">
|
||||
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
|
||||
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
|
||||
{{else}}
|
||||
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
|
||||
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
|
||||
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}}
|
||||
{{else}}
|
||||
{{mailName (decodeHeader .FromAddr)}}
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="cell-from" title="收件人:{{.ToAddr}}">致:{{mailName .ToAddr}}</span>
|
||||
<span class="cell-subject-wrap">
|
||||
<a class="cell-subject" href="/drafts/{{.ID}}">
|
||||
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
|
||||
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</span>
|
||||
@@ -53,12 +70,21 @@
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
||||
</span>
|
||||
<span class="cell-date">{{shortDate .Date}}</span>
|
||||
{{if $.isTrash}}
|
||||
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
|
||||
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');">
|
||||
<button type="submit" class="icon-btn" title="恢复到收件箱">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
|
||||
onsubmit="return confirm('确定要删除这封草稿吗?');">
|
||||
onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="icon-btn" title="删除">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -68,7 +94,7 @@
|
||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
||||
<div class="pager">
|
||||
{{if gt .page 1}}
|
||||
<a class="page-btn" href="/drafts?page={{sub .page 1}}">
|
||||
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
上一页
|
||||
</a>
|
||||
@@ -76,7 +102,7 @@
|
||||
<span class="page-btn disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if lt .page .totalPages}}
|
||||
<a class="page-btn" href="/drafts?page={{add .page 1}}">
|
||||
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
|
||||
下一页
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
@@ -1,88 +0,0 @@
|
||||
{{define "inbox"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>收件箱 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
{{template "navbar" .}}
|
||||
<div class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="mail-main">
|
||||
<div class="list-toolbar">
|
||||
<label class="check-all" title="全选/取消全选">
|
||||
<input type="checkbox" id="select-all">
|
||||
全选
|
||||
</label>
|
||||
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
删除
|
||||
</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<span class="page-info">共 {{.total}} 封</span>
|
||||
</div>
|
||||
|
||||
{{if not .messages}}
|
||||
<div class="mail-list">
|
||||
<div class="empty-tip"><span class="empty-icon">📭</span>收件箱暂无邮件</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
|
||||
</span>
|
||||
<span class="cell-from" title="{{decodeHeader .FromAddr}}">{{mailName (decodeHeader .FromAddr)}}</span>
|
||||
<span class="cell-subject-wrap">
|
||||
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
|
||||
<a class="cell-subject" href="/inbox/{{.ID}}">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cell-snippet">
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
||||
</span>
|
||||
<span class="cell-date">{{shortDate .Date}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<div class="list-footer">
|
||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
||||
<div class="pager">
|
||||
{{if gt .page 1}}
|
||||
<a class="page-btn" href="/inbox?page={{sub .page 1}}">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
上一页
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if lt .page .totalPages}}
|
||||
<a class="page-btn" href="/inbox?page={{add .page 1}}">
|
||||
下一页
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">下一页</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{template "listjs" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,93 +0,0 @@
|
||||
{{define "sent"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>已发送 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
{{template "navbar" .}}
|
||||
<div class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="mail-main">
|
||||
<div class="list-toolbar">
|
||||
<label class="check-all" title="全选/取消全选">
|
||||
<input type="checkbox" id="select-all">
|
||||
全选
|
||||
</label>
|
||||
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
删除
|
||||
</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<span class="page-info">共 {{.total}} 封</span>
|
||||
</div>
|
||||
|
||||
{{if not .messages}}
|
||||
<div class="mail-list">
|
||||
<div class="empty-tip"><span class="empty-icon">📤</span>已发送暂无邮件</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
|
||||
</span>
|
||||
<span class="cell-from" title="收件人:{{.ToAddr}}">{{mailName .ToAddr}}</span>
|
||||
<span class="cell-subject-wrap">
|
||||
<a class="cell-subject" href="/sent/{{.ID}}">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cell-snippet">
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
||||
</span>
|
||||
<span class="cell-date">{{shortDate .Date}}</span>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="icon-btn" title="删除">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<div class="list-footer">
|
||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
||||
<div class="pager">
|
||||
{{if gt .page 1}}
|
||||
<a class="page-btn" href="/sent?page={{sub .page 1}}">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
上一页
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if lt .page .totalPages}}
|
||||
<a class="page-btn" href="/sent?page={{add .page 1}}">
|
||||
下一页
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">下一页</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{template "listjs" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -21,6 +21,21 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||
回复
|
||||
</a>
|
||||
{{if .inTrash}}
|
||||
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
|
||||
<button type="submit" class="tb-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
||||
恢复
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
|
||||
<button type="submit" class="tb-btn danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
彻底删除
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="tb-btn danger">
|
||||
@@ -28,6 +43,7 @@
|
||||
删除
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mail-head">
|
||||
@@ -74,6 +90,21 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||
回复
|
||||
</a>
|
||||
{{if .inTrash}}
|
||||
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
|
||||
<button type="submit" class="btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
||||
恢复到收件箱
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
彻底删除
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
@@ -81,6 +112,7 @@
|
||||
删除邮件
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user