二阶段差不多

This commit is contained in:
2026-06-01 19:46:51 +08:00
parent 9e50d05e71
commit 4e233c82b4
34 changed files with 1631 additions and 67 deletions
+239 -17
View File
@@ -2,10 +2,13 @@ package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"mail_go/internal/db"
"mail_go/internal/dkim"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
@@ -27,13 +30,55 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
_, domainCount, _ := h.stores.Domains.List(1, 1)
_, userCount, _ := h.stores.Users.ListAll(1, 1)
// Mail statistics
totalMails, _ := h.stores.Mails.CountAll()
inboxCount, _ := h.stores.Mails.CountByFolder("INBOX")
sentCount, _ := h.stores.Mails.CountByFolder("Sent")
draftsCount, _ := h.stores.Mails.CountByFolder("Drafts")
trashCount, _ := h.stores.Mails.CountByFolder("Trash")
totalSize, _ := h.stores.Mails.TotalSize()
inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX")
sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent")
// Today and weekly statistics
todayStart := time.Now().Truncate(24 * time.Hour)
weekStart := time.Now().AddDate(0, 0, -7)
todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart)
todaySent, _ := h.stores.Mails.CountByFolderSince("Sent", todayStart)
weekReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", weekStart)
weekSent, _ := h.stores.Mails.CountByFolderSince("Sent", weekStart)
// Ban count: number of currently banned IPs
bans, _, _ := h.stores.Bans.List(1, 1000)
var banCount int64
for _, b := range bans {
if b.ExpiresAt.After(time.Now()) {
banCount++
}
}
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_dashboard", gin.H{
"currentUser": currentUser,
"domainCount": domainCount,
"userCount": userCount,
"activeFolder": "admin",
"currentUser": currentUser,
"domainCount": domainCount,
"userCount": userCount,
"totalMails": totalMails,
"inboxCount": inboxCount,
"sentCount": sentCount,
"draftsCount": draftsCount,
"trashCount": trashCount,
"totalSize": totalSize,
"inboxSize": inboxSize,
"sentSize": sentSize,
"todayReceived": todayReceived,
"todaySent": todaySent,
"weekReceived": weekReceived,
"weekSent": weekSent,
"banCount": banCount,
"activeFolder": "admin",
})
}
@@ -63,7 +108,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"activeFolder": "admin",
"activeFolder": "domains",
})
}
@@ -73,7 +118,7 @@ func (h *AdminHandler) NewDomain(c *gin.Context) {
c.HTML(200, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "domains",
"error": "",
"isEdit": false,
"domain": &db.Domain{},
@@ -92,7 +137,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "domains",
"error": "请输入域名",
"isEdit": false,
"domain": &db.Domain{
@@ -114,11 +159,21 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
TlsEnabled: tlsEnabled,
}
// 自动生成 DKIM 密钥对
privKey, pubKey, err := dkim.GenerateKeyPair()
if err != nil {
log.Printf("DKIM密钥生成失败: %v", err)
} else {
domain.DkimSelector = "default"
domain.DkimPrivateKey = privKey
domain.DkimPublicKey = pubKey
}
if err := h.stores.Domains.Create(domain); err != nil {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "domains",
"error": fmt.Sprintf("创建域名失败: %v", err),
"isEdit": false,
"domain": domain,
@@ -129,6 +184,74 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/domains")
}
// EditDomain 渲染编辑域名表单
func (h *AdminHandler) EditDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
return
}
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": "",
"isEdit": true,
"domain": domain,
})
}
// UpdateDomain 处理编辑域名表单提交
func (h *AdminHandler) UpdateDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
return
}
domain.SmtpPort = formIntOrDefault(c, "smtp_port", domain.SmtpPort)
domain.ImapPort = formIntOrDefault(c, "imap_port", domain.ImapPort)
domain.Pop3Port = formIntOrDefault(c, "pop3_port", domain.Pop3Port)
domain.TlsEnabled = c.PostForm("tls_enabled") == "on"
// 重新生成DKIM
if c.PostForm("regenerate_dkim") == "on" {
privKey, pubKey, err := dkim.GenerateKeyPair()
if err == nil {
domain.DkimPrivateKey = privKey
domain.DkimPublicKey = pubKey
domain.DkimSelector = "default"
}
}
if err := h.stores.Domains.Update(domain); err != nil {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": fmt.Sprintf("更新域名失败: %v", err),
"isEdit": true,
"domain": domain,
})
return
}
c.Redirect(http.StatusFound, "/admin/domains")
}
// DeleteDomain removes a domain by ID.
func (h *AdminHandler) DeleteDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -163,8 +286,9 @@ func (h *AdminHandler) DNSHint(c *gin.Context) {
c.HTML(200, "admin_dns_hint", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "domains",
"domain": domain,
"dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey),
})
}
@@ -199,7 +323,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"activeFolder": "admin",
"activeFolder": "users",
})
}
@@ -211,7 +335,7 @@ func (h *AdminHandler) NewUser(c *gin.Context) {
c.HTML(200, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": "",
"isEdit": false,
"domains": domains,
@@ -232,7 +356,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": "请填写所有必填字段",
"isEdit": false,
"domains": domains,
@@ -252,7 +376,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": "密码加密失败",
"isEdit": false,
"domains": domains,
@@ -282,7 +406,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": fmt.Sprintf("创建用户失败: %v", err),
"isEdit": false,
"domains": domains,
@@ -335,7 +459,7 @@ func (h *AdminHandler) EditUser(c *gin.Context) {
c.HTML(200, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": "",
"isEdit": true,
"domains": domains,
@@ -380,7 +504,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": "密码加密失败",
"isEdit": true,
"domains": domains,
@@ -396,7 +520,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
"currentUser": currentUser,
"activeFolder": "admin",
"activeFolder": "users",
"error": fmt.Sprintf("更新用户失败: %v", err),
"isEdit": true,
"domains": domains,
@@ -408,6 +532,104 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/users")
}
// ListBans renders the IP ban list page.
func (h *AdminHandler) ListBans(c *gin.Context) {
// Clean up expired entries first
h.stores.Bans.Cleanup()
page := getPageParam(c, "page", 1)
bans, total, err := h.stores.Bans.List(page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载黑名单失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "admin_bans", gin.H{
"currentUser": currentUser,
"bans": bans,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"activeFolder": "bans",
})
}
// UnbanIP removes a ban entry by ID.
func (h *AdminHandler) UnbanIP(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的记录ID")
return
}
if err := h.stores.Bans.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "解封失败: %v", err)
return
}
c.Redirect(http.StatusFound, "/admin/bans")
}
// CleanupBans removes all expired ban entries.
func (h *AdminHandler) CleanupBans(c *gin.Context) {
h.stores.Bans.Cleanup()
c.Redirect(http.StatusFound, "/admin/bans")
}
// ListMails renders the admin mail list page showing all messages across all users.
func (h *AdminHandler) ListMails(c *gin.Context) {
page := getPageParam(c, "page", 1)
folder := c.Query("folder")
var messages []db.Message
var total int64
var err error
if folder != "" {
messages, total, err = h.stores.Mails.ListAllByFolder(folder, page, 20)
} else {
messages, total, err = h.stores.Mails.ListAll(page, 20)
}
if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
return
}
currentUser, _ := c.Get("currentUser")
totalPages := int(total) / 20
if int(total)%20 > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 0
}
c.HTML(200, "admin_mails", gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
"page": page,
"pageSize": 20,
"totalPages": totalPages,
"folder": folder,
"activeFolder": "mails",
})
}
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
func formIntOrDefault(c *gin.Context, key string, defaultVal int) int {
val := c.PostForm(key)
+244 -9
View File
@@ -1,20 +1,30 @@
package handlers
import (
"fmt"
"log"
"net/http"
"time"
"mail_go/config"
"mail_go/internal/auth"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// AuthHandler handles authentication-related routes (login, logout).
// AuthHandler handles authentication-related routes (login, logout, LDAP, OAuth2).
type AuthHandler struct {
stores *store.Stores
stores *store.Stores
authCfg config.AuthConfig
banCfg config.BanConfig
}
// NewAuthHandler creates a new AuthHandler with the given stores.
func NewAuthHandler(stores *store.Stores) *AuthHandler {
return &AuthHandler{stores: stores}
// NewAuthHandler creates a new AuthHandler with the given stores, auth config, and ban config.
func NewAuthHandler(stores *store.Stores, authCfg config.AuthConfig, banCfg config.BanConfig) *AuthHandler {
return &AuthHandler{stores: stores, authCfg: authCfg, banCfg: banCfg}
}
// ShowLogin renders the login page.
@@ -26,7 +36,10 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
return
}
c.HTML(200, "login", gin.H{
"error": "",
"error": "",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}
@@ -34,20 +47,239 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
// It authenticates the user with email and password, sets session data
// on success, or re-renders the login page with an error on failure.
func (h *AuthHandler) DoLogin(c *gin.Context) {
ip := c.ClientIP()
// Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip)
if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
return
}
email := c.PostForm("email")
password := c.PostForm("password")
if email == "" || password == "" {
c.HTML(200, "login", gin.H{
"error": "请输入邮箱和密码",
"error": "请输入邮箱和密码",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
user, err := h.stores.Users.Authenticate(email, password)
if err != nil {
failCount, _ := h.stores.Bans.IncrementFail(ip)
if failCount >= h.banCfg.MaxFailAttempts {
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
banEntry := &db.BanEntry{
IPAddress: ip,
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
FailCount: failCount,
ExpiresAt: time.Now().Add(banDuration),
}
h.stores.Bans.Create(banEntry)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": "用户名或密码错误",
"error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
// Login successful: reset fail count
h.stores.Bans.ResetFail(ip)
// Set session values
session := sessions.Default(c)
session.Set("userID", user.ID)
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
session.Set("isAdmin", user.IsAdmin)
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
c.Redirect(302, "/inbox")
}
// LDAPLogin handles LDAP authentication form submission.
func (h *AuthHandler) LDAPLogin(c *gin.Context) {
ip := c.ClientIP()
// Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip)
if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
return
}
username := c.PostForm("username")
password := c.PostForm("password")
if username == "" || password == "" {
c.HTML(200, "login", gin.H{
"error": "请输入LDAP用户名和密码",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
provider := auth.NewLDAPProvider(h.authCfg)
email, err := provider.Authenticate(map[string]string{
"username": username,
"password": password,
})
if err != nil {
log.Printf("LDAP 认证失败: %v", err)
failCount, _ := h.stores.Bans.IncrementFail(ip)
if failCount >= h.banCfg.MaxFailAttempts {
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
banEntry := &db.BanEntry{
IPAddress: ip,
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
FailCount: failCount,
ExpiresAt: time.Now().Add(banDuration),
}
h.stores.Bans.Create(banEntry)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会: %v", remaining, err),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
// Look up or auto-create user by email
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 用户 %s 在系统中不存在", email),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
if !user.IsActive {
c.HTML(200, "login", gin.H{
"error": "用户已被禁用",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
// Login successful: reset fail count
h.stores.Bans.ResetFail(ip)
// Set session values
session := sessions.Default(c)
session.Set("userID", user.ID)
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
session.Set("isAdmin", user.IsAdmin)
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
c.Redirect(302, "/inbox")
}
// OAuth2Start redirects to the OAuth2 provider's authorization page.
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用")
return
}
provider := auth.NewOAuth2Provider(h.authCfg)
// Use a simple state for CSRF protection (in production, use a random token)
state := "mailgo_oauth2_state"
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
}
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用")
return
}
code := c.Query("code")
if code == "" {
c.HTML(200, "login", gin.H{
"error": "OAuth2 授权码缺失",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
provider := auth.NewOAuth2Provider(h.authCfg)
email, err := provider.HandleCallback(code)
if err != nil {
log.Printf("OAuth2 回调失败: %v", err)
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 认证失败: %v", err),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
// Look up user by email
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 用户 %s 在系统中不存在", email),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
if !user.IsActive {
c.HTML(200, "login", gin.H{
"error": "用户已被禁用",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
@@ -59,7 +291,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin)
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
"error": "会话保存失败,请重试",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
return
}
+89 -6
View File
@@ -108,14 +108,27 @@ func (h *MailHandler) View(c *gin.Context) {
// 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,
})
}
@@ -129,6 +142,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
to := c.PostForm("to")
subject := c.PostForm("subject")
body := c.PostForm("body")
htmlBody := c.PostForm("html_body")
cc := c.PostForm("cc")
if to == "" {
@@ -139,10 +153,43 @@ func (h *MailHandler) DoSend(c *gin.Context) {
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
// Handle attachments and check quota
form, multipartErr := c.MultipartForm()
if multipartErr == nil {
files := form.File["attachments"]
if len(files) > 0 {
// Check attachment quota before saving
user, _ := h.stores.Users.GetByID(userID)
if user != nil {
var totalNewSize int64
for _, file := range files {
totalNewSize += file.Size
}
if user.UsedBytes+totalNewSize > user.QuotaBytes {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(user.UsedBytes), formatBytes(user.QuotaBytes)),
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes,
})
return
}
}
}
}
// Build the email content
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
now := time.Now()
@@ -159,9 +206,24 @@ func (h *MailHandler) DoSend(c *gin.Context) {
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")
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
sb.WriteString("\r\n")
sb.WriteString(body)
// 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)
}
// Send via local SMTP
err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String()))
@@ -180,6 +242,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
CcAddr: cc,
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
Date: now,
IsRead: true,
}
@@ -192,13 +255,15 @@ func (h *MailHandler) DoSend(c *gin.Context) {
"to": to,
"subject": subject,
"cc": cc,
"bodyContent": htmlBody,
"usedBytes": currentUser.UsedBytes,
"quotaBytes": currentUser.QuotaBytes,
})
return
}
// Handle attachments
form, err := c.MultipartForm()
if err == nil {
if multipartErr == nil {
files := form.File["attachments"]
for _, file := range files {
// Read file content
@@ -233,6 +298,8 @@ func (h *MailHandler) DoSend(c *gin.Context) {
FileSize: file.Size,
}
_ = h.stores.Attachments.Create(att)
// Update user used bytes
_ = h.stores.Users.UpdateUsedBytes(userID, att.FileSize)
}
}
@@ -306,10 +373,11 @@ func (h *MailHandler) Delete(c *gin.Context) {
return
}
// Delete attachments on disk and in DB
// 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))
@@ -519,3 +587,18 @@ func resolveActiveFolder(folder string) string {
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])
}