二阶段差不多
This commit is contained in:
+239
-17
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BanMiddleware checks if the client IP is currently banned.
|
||||
// If banned, it renders the "banned" template and aborts the request.
|
||||
func BanMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
banned, entry := stores.Bans.IsBanned(ip)
|
||||
if banned {
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{
|
||||
"entry": entry,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -36,6 +36,8 @@ type WebServer struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.WebConfig
|
||||
authCfg config.AuthConfig
|
||||
banCfg config.BanConfig
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
@@ -60,6 +62,9 @@ func templateFuncs() template.FuncMap {
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"safeJS": func(s string) template.JS {
|
||||
return template.JS(s)
|
||||
},
|
||||
"formatBytes": func(b int64) string {
|
||||
return formatBytes(b)
|
||||
},
|
||||
@@ -68,7 +73,7 @@ func templateFuncs() template.FuncMap {
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||
// configures sessions, middleware, and registers all routes.
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *WebServer {
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, authCfg config.AuthConfig, banCfg config.BanConfig) *WebServer {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
@@ -95,6 +100,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
stores: stores,
|
||||
storage: attStorage,
|
||||
cfg: cfg,
|
||||
authCfg: authCfg,
|
||||
banCfg: banCfg,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
@@ -103,13 +110,19 @@ 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)
|
||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores)
|
||||
|
||||
// Apply BanMiddleware globally before public routes
|
||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||
|
||||
// Public routes (no auth required)
|
||||
ws.engine.GET("/login", authHandler.ShowLogin)
|
||||
ws.engine.POST("/login", authHandler.DoLogin)
|
||||
ws.engine.POST("/login/ldap", authHandler.LDAPLogin)
|
||||
ws.engine.GET("/auth/oauth2", authHandler.OAuth2Start)
|
||||
ws.engine.GET("/auth/oauth2/callback", authHandler.OAuth2Callback)
|
||||
|
||||
// Auth-protected routes
|
||||
auth := ws.engine.Group("")
|
||||
@@ -146,6 +159,8 @@ func (ws *WebServer) registerRoutes() {
|
||||
admin.GET("/domains", adminHandler.ListDomains)
|
||||
admin.GET("/domains/new", adminHandler.NewDomain)
|
||||
admin.POST("/domains", adminHandler.CreateDomain)
|
||||
admin.GET("/domains/:id/edit", adminHandler.EditDomain)
|
||||
admin.POST("/domains/:id", adminHandler.UpdateDomain)
|
||||
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
||||
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
@@ -154,6 +169,10 @@ func (ws *WebServer) registerRoutes() {
|
||||
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||||
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
||||
admin.POST("/users/:id", adminHandler.UpdateUser)
|
||||
admin.GET("/mails", adminHandler.ListMails)
|
||||
admin.GET("/bans", adminHandler.ListBans)
|
||||
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||||
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "admin_bans"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IP黑名单 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>IP 黑名单</h2>
|
||||
<form method="POST" action="/admin/bans/cleanup" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary">清理过期记录</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if not .bans}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无被封禁的 IP</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP 地址</th>
|
||||
<th>失败次数</th>
|
||||
<th>原因</th>
|
||||
<th>到期时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .bans}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.IPAddress}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
|
||||
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?');">
|
||||
<button type="submit" class="btn btn-primary btn-sm">解封</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin" class="active">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:24px;">管理后台</h2>
|
||||
@@ -28,12 +30,53 @@
|
||||
<h3>{{.userCount}}</h3>
|
||||
<p>用户数</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.totalMails}}</h3>
|
||||
<p>邮件总数</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.banCount}}</h3>
|
||||
<p>被封IP</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>邮件分布</h3>
|
||||
<table style="margin-top:12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件夹</th>
|
||||
<th>邮件数</th>
|
||||
<th>占用空间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>收件箱 (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
|
||||
<tr><td>发件箱 (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
|
||||
<tr><td>草稿箱 (Drafts)</td><td>{{.draftsCount}}</td><td>—</td></tr>
|
||||
<tr><td>垃圾箱 (Trash)</td><td>{{.trashCount}}</td><td>—</td></tr>
|
||||
<tr style="font-weight:bold;"><td>合计</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>收发统计</h3>
|
||||
<table style="margin-top:12px;">
|
||||
<thead>
|
||||
<tr><th>时间段</th><th>收件</th><th>发件</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>今日</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
|
||||
<tr><td>近 7 天</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>快捷操作</h3>
|
||||
<p style="margin-top:12px;">
|
||||
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
||||
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">新增用户</a>
|
||||
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">查看所有邮件</a>
|
||||
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">IP黑名单</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
@@ -28,8 +30,12 @@
|
||||
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
|
||||
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
|
||||
|
||||
<h4 style="margin-bottom:8px;">DKIM 记录</h4>
|
||||
<div class="dns-record" style="color:#e67e22;">⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。</div>
|
||||
<h4 style="margin-bottom:8px;">DKIM 记录 (TXT)</h4>
|
||||
{{if .domain.DkimPublicKey}}
|
||||
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
|
||||
{{else}}
|
||||
<div class="dns-record" style="color:#e67e22;">⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。</div>
|
||||
{{end}}
|
||||
|
||||
<h4 style="margin-bottom:8px;">DMARC 记录</h4>
|
||||
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
@@ -24,7 +26,11 @@
|
||||
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
|
||||
<div class="form-group">
|
||||
<label>域名</label>
|
||||
{{if .isEdit}}
|
||||
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
|
||||
{{else}}
|
||||
<input type="text" name="name" required value="{{.domain.Name}}" placeholder="example.com">
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SMTP 端口</label>
|
||||
@@ -44,6 +50,19 @@
|
||||
启用 TLS
|
||||
</label>
|
||||
</div>
|
||||
{{if .isEdit}}
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="regenerate_dkim">
|
||||
重新生成 DKIM 密钥
|
||||
</label>
|
||||
{{if .domain.DkimPublicKey}}
|
||||
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ DKIM 密钥已配置</p>
|
||||
{{else}}
|
||||
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ DKIM 密钥未配置</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
|
||||
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||
</form>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
@@ -48,6 +50,7 @@
|
||||
<td>{{.Pop3Port}}</td>
|
||||
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
|
||||
<td>
|
||||
<a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
|
||||
<a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a>
|
||||
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{{define "admin_mails"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>所有邮件 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">所有邮件</h2>
|
||||
<div style="margin-bottom:16px;">
|
||||
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>全部</a>
|
||||
<a href="/admin/mails?folder=INBOX" {{if eq .folder "INBOX"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>INBOX</a>
|
||||
<a href="/admin/mails?folder=Sent" {{if eq .folder "Sent"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Sent</a>
|
||||
<a href="/admin/mails?folder=Drafts" {{if eq .folder "Drafts"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Drafts</a>
|
||||
<a href="/admin/mails?folder=Trash" {{if eq .folder "Trash"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Trash</a>
|
||||
</div>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>发件人</th>
|
||||
<th>收件人</th>
|
||||
<th>主题</th>
|
||||
<th>所属用户</th>
|
||||
<th>文件夹</th>
|
||||
<th>日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr>
|
||||
<td>{{.FromAddr}}</td>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>{{.Subject}}</td>
|
||||
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
|
||||
<td>{{.Folder}}</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users" class="active">用户管理</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users" class="active">用户管理</a>
|
||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{{define "banned"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>访问被禁止 - MailGo</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.banned-card { background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 48px; text-align: center; max-width: 480px; width: 100%; }
|
||||
.banned-icon { font-size: 64px; margin-bottom: 16px; }
|
||||
h1 { font-size: 24px; color: #c0392b; margin-bottom: 12px; }
|
||||
p { color: #7f8c8d; line-height: 1.6; margin-bottom: 8px; }
|
||||
.detail { background: #f8f9fa; border-radius: 6px; padding: 16px; margin: 16px 0; text-align: left; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #eee; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { color: #7f8c8d; font-size: 13px; }
|
||||
.detail-value { color: #2c3e50; font-weight: 600; font-size: 13px; }
|
||||
.back-link { display: inline-block; margin-top: 20px; color: #3498db; text-decoration: none; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="banned-card">
|
||||
<div class="banned-icon">🚫</div>
|
||||
<h1>您的 IP 已被暂时禁止访问</h1>
|
||||
<p>由于登录失败次数过多,您的 IP 地址已被暂时封禁。</p>
|
||||
{{if .entry}}
|
||||
<div class="detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">IP 地址</span>
|
||||
<span class="detail-value">{{.entry.IPAddress}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">原因</span>
|
||||
<span class="detail-value">{{.entry.Reason}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">失败次数</span>
|
||||
<span class="detail-value">{{.entry.FailCount}} 次</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">解封时间</span>
|
||||
<span class="detail-value">{{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<p style="font-size:13px;">请在解封时间后重试,或联系管理员。</p>
|
||||
<a href="/login" class="back-link">返回登录页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>撰写邮件 - MailGo</title>
|
||||
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
@@ -37,12 +38,17 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>正文</label>
|
||||
<textarea name="body" rows="12" placeholder="请输入邮件内容..."></textarea>
|
||||
<div id="editor" style="height:300px;"></div>
|
||||
<input type="hidden" name="body" id="body-hidden">
|
||||
<input type="hidden" name="html_body" id="html-body-hidden">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>附件</label>
|
||||
<input type="file" name="attachments" multiple>
|
||||
</div>
|
||||
<div class="form-group" style="color:#7f8c8d;font-size:12px;">
|
||||
配额: {{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">发送邮件</button>
|
||||
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
|
||||
</form>
|
||||
@@ -50,6 +56,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
|
||||
<script>
|
||||
var quill = new Quill('#editor', {
|
||||
theme: 'snow',
|
||||
placeholder: '请输入邮件内容...',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
document.querySelector('form').addEventListener('submit', function() {
|
||||
document.getElementById('body-hidden').value = quill.getText();
|
||||
document.getElementById('html-body-hidden').value = quill.root.innerHTML;
|
||||
});
|
||||
{{if .bodyContent}}
|
||||
quill.root.innerHTML = {{.bodyContent | safeJS}};
|
||||
{{end}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -25,6 +25,30 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
||||
</form>
|
||||
|
||||
{{if or .oauth2Enabled .ldapEnabled}}
|
||||
<div style="text-align:center;margin:16px 0;color:#7f8c8d;">─── 或 ───</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ldapEnabled}}
|
||||
<form method="POST" action="/login/ldap">
|
||||
<div class="form-group">
|
||||
<label>LDAP 用户名</label>
|
||||
<input type="text" name="username" placeholder="LDAP 用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>LDAP 密码</label>
|
||||
<input type="password" name="password" placeholder="LDAP 密码">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="width:100%;background:#8e44ad;color:#fff;">LDAP 登录</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{if .oauth2Enabled}}
|
||||
<a href="/auth/oauth2" class="btn" style="width:100%;background:#3498db;color:#fff;text-align:center;display:block;margin-top:8px;">
|
||||
{{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} 登录
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user