一阶段ok
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
||||
type AdminHandler struct {
|
||||
stores *store.Stores
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores.
|
||||
func NewAdminHandler(stores *store.Stores) *AdminHandler {
|
||||
return &AdminHandler{stores: stores}
|
||||
}
|
||||
|
||||
// Dashboard renders the admin dashboard with summary statistics.
|
||||
func (h *AdminHandler) Dashboard(c *gin.Context) {
|
||||
_, domainCount, _ := h.stores.Domains.List(1, 1)
|
||||
_, userCount, _ := h.stores.Users.ListAll(1, 1)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_dashboard", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"domainCount": domainCount,
|
||||
"userCount": userCount,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// ListDomains renders the domain list page.
|
||||
func (h *AdminHandler) ListDomains(c *gin.Context) {
|
||||
page := getPageParam(c, "page", 1)
|
||||
domains, total, err := h.stores.Domains.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_domains", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"domains": domains,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// NewDomain renders the new domain form page.
|
||||
func (h *AdminHandler) NewDomain(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_domain_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "",
|
||||
"isEdit": false,
|
||||
"domain": &db.Domain{},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDomain processes the new domain form submission.
|
||||
func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
||||
name := c.PostForm("name")
|
||||
smtpPort := formIntOrDefault(c, "smtp_port", 25)
|
||||
imapPort := formIntOrDefault(c, "imap_port", 143)
|
||||
pop3Port := formIntOrDefault(c, "pop3_port", 110)
|
||||
tlsEnabled := c.PostForm("tls_enabled") == "on"
|
||||
|
||||
if name == "" {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "请输入域名",
|
||||
"isEdit": false,
|
||||
"domain": &db.Domain{
|
||||
Name: name,
|
||||
SmtpPort: smtpPort,
|
||||
ImapPort: imapPort,
|
||||
Pop3Port: pop3Port,
|
||||
TlsEnabled: tlsEnabled,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
domain := &db.Domain{
|
||||
Name: name,
|
||||
SmtpPort: smtpPort,
|
||||
ImapPort: imapPort,
|
||||
Pop3Port: pop3Port,
|
||||
TlsEnabled: tlsEnabled,
|
||||
}
|
||||
|
||||
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",
|
||||
"error": fmt.Sprintf("创建域名失败: %v", err),
|
||||
"isEdit": false,
|
||||
"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)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Domains.Delete(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "删除域名失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/domains")
|
||||
}
|
||||
|
||||
// DNSHint renders the DNS configuration hints for a specific domain.
|
||||
func (h *AdminHandler) DNSHint(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_dns_hint", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"domain": domain,
|
||||
})
|
||||
}
|
||||
|
||||
// ListUsers renders the user list page.
|
||||
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
users, total, err := h.stores.Users.ListAll(page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all domains for display
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "admin_users", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"users": users,
|
||||
"domains": domains,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// NewUser renders the new user form page.
|
||||
func (h *AdminHandler) NewUser(c *gin.Context) {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "",
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": &db.User{},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser processes the new user form submission.
|
||||
func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
domainID := formUintOrDefault(c, "domain_id", 0)
|
||||
quotaGB := formIntOrDefault(c, "quota_gb", 5)
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
|
||||
if username == "" || password == "" || domainID == 0 {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "请填写所有必填字段",
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": &db.User{
|
||||
Username: username,
|
||||
DomainID: domainID,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "密码加密失败",
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": &db.User{
|
||||
Username: username,
|
||||
DomainID: domainID,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
quotaBytes := int64(quotaGB) * 1024 * 1024 * 1024
|
||||
|
||||
user := &db.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hashedPassword),
|
||||
DomainID: domainID,
|
||||
QuotaBytes: quotaBytes,
|
||||
UsedBytes: 0,
|
||||
IsActive: true,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
if err := h.stores.Users.Create(user); err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": fmt.Sprintf("创建用户失败: %v", err),
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// DeleteUser removes a user by ID.
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
if currentUser.(*db.User).ID == uint(id) {
|
||||
c.String(http.StatusBadRequest, "不能删除自己的账户")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Users.Delete(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "删除用户失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// EditUser renders the edit user form page.
|
||||
func (h *AdminHandler) EditUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "",
|
||||
"isEdit": true,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUser processes the edit user form submission.
|
||||
func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
username := c.PostForm("username")
|
||||
domainID := formUintOrDefault(c, "domain_id", user.DomainID)
|
||||
quotaGB := formIntOrDefault(c, "quota_gb", int(user.QuotaBytes/(1024*1024*1024)))
|
||||
isActive := c.PostForm("is_active") == "on"
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
password := c.PostForm("password")
|
||||
|
||||
if username != "" {
|
||||
user.Username = username
|
||||
}
|
||||
user.DomainID = domainID
|
||||
user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024
|
||||
user.IsActive = isActive
|
||||
user.IsAdmin = isAdmin
|
||||
|
||||
// Update password only if a new one is provided
|
||||
if password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "密码加密失败",
|
||||
"isEdit": true,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
}
|
||||
|
||||
if err := h.stores.Users.Update(user); err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": fmt.Sprintf("更新用户失败: %v", err),
|
||||
"isEdit": true,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// 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)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// formUintOrDefault extracts a uint from a form field, returning the default if missing/invalid.
|
||||
func formUintOrDefault(c *gin.Context, key string, defaultVal uint) uint {
|
||||
val := c.PostForm(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.ParseUint(val, 10, 64)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related routes (login, logout).
|
||||
type AuthHandler struct {
|
||||
stores *store.Stores
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler with the given stores.
|
||||
func NewAuthHandler(stores *store.Stores) *AuthHandler {
|
||||
return &AuthHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ShowLogin renders the login page.
|
||||
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||
// If already logged in, redirect to inbox
|
||||
session := sessions.Default(c)
|
||||
if session.Get("userID") != nil {
|
||||
c.Redirect(302, "/inbox")
|
||||
return
|
||||
}
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "",
|
||||
})
|
||||
}
|
||||
|
||||
// DoLogin processes the login form submission.
|
||||
// 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) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "请输入邮箱和密码",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.Authenticate(email, password)
|
||||
if err != nil {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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": "会话保存失败,请重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(302, "/inbox")
|
||||
}
|
||||
|
||||
// DoLogout clears the session and redirects to the login page.
|
||||
func (h *AuthHandler) DoLogout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
|
||||
type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
}
|
||||
|
||||
// NewMailHandler creates a new MailHandler with the given stores and attachment storage.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage}
|
||||
}
|
||||
|
||||
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
|
||||
func (h *MailHandler) Inbox(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
unreadCount, _ := h.stores.Mails.CountUnread(userID, "INBOX")
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "inbox", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"unreadCount": unreadCount,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "INBOX",
|
||||
"activeFolder": "inbox",
|
||||
})
|
||||
}
|
||||
|
||||
// View renders the email detail page for a specific message.
|
||||
func (h *MailHandler) View(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "邮件不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message belongs to the current user
|
||||
if msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
// Load attachments
|
||||
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||
|
||||
// Auto mark as read
|
||||
if !msg.IsRead {
|
||||
_ = h.stores.Mails.MarkRead(uint(id))
|
||||
msg.IsRead = true
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "view", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"message": msg,
|
||||
"attachments": attachments,
|
||||
"activeFolder": resolveActiveFolder(msg.Folder),
|
||||
})
|
||||
}
|
||||
|
||||
// Compose renders the email composition page.
|
||||
func (h *MailHandler) Compose(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "",
|
||||
"to": c.Query("to"),
|
||||
"subject": c.Query("subject"),
|
||||
})
|
||||
}
|
||||
|
||||
// DoSend processes the email composition form, sends the email via SMTP,
|
||||
// and stores the message record.
|
||||
func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
currentUserVal, _ := c.Get("currentUser")
|
||||
currentUser := currentUserVal.(*db.User)
|
||||
|
||||
to := c.PostForm("to")
|
||||
subject := c.PostForm("subject")
|
||||
body := c.PostForm("body")
|
||||
cc := c.PostForm("cc")
|
||||
|
||||
if to == "" {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "请输入收件人",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the email content
|
||||
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
|
||||
now := time.Now()
|
||||
messageID := fmt.Sprintf("<%s@mail_go>", uuid.New().String())
|
||||
|
||||
// Construct the raw email message
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr))
|
||||
sb.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
if cc != "" {
|
||||
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", cc))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
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()))
|
||||
if err != nil {
|
||||
// Log the error but still save to sent folder — SMTP may not be running yet
|
||||
fmt.Printf("SMTP发送失败(邮件仍保存到发件箱): %v\n", err)
|
||||
}
|
||||
|
||||
// Save to Sent folder
|
||||
msg := &db.Message{
|
||||
UserID: userID,
|
||||
MessageID: messageID,
|
||||
Folder: "Sent",
|
||||
FromAddr: fromAddr,
|
||||
ToAddr: to,
|
||||
CcAddr: cc,
|
||||
Subject: subject,
|
||||
TextBody: body,
|
||||
Date: now,
|
||||
IsRead: true,
|
||||
}
|
||||
|
||||
if createErr := h.stores.Mails.Create(msg); createErr != nil {
|
||||
c.HTML(http.StatusInternalServerError, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("保存邮件失败: %v", createErr),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
form, err := c.MultipartForm()
|
||||
if err == nil {
|
||||
files := form.File["attachments"]
|
||||
for _, file := range files {
|
||||
// Read file content
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
relPath, err := h.storage.Save(file.Filename, buf)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine content type from extension
|
||||
contentType := "application/octet-stream"
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if ct, ok := mimeTypes[ext]; ok {
|
||||
contentType = ct
|
||||
}
|
||||
|
||||
att := &db.Attachment{
|
||||
MessageID: msg.ID,
|
||||
FileName: file.Filename,
|
||||
FilePath: relPath,
|
||||
ContentType: contentType,
|
||||
FileSize: file.Size,
|
||||
}
|
||||
_ = h.stores.Attachments.Create(att)
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/sent")
|
||||
}
|
||||
|
||||
// mimeTypes maps common file extensions to MIME types.
|
||||
var mimeTypes = map[string]string{
|
||||
".txt": "text/plain",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".zip": "application/zip",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".csv": "text/csv",
|
||||
}
|
||||
|
||||
// Sent renders the sent mail folder page.
|
||||
func (h *MailHandler) Sent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a message by ID after verifying ownership.
|
||||
func (h *MailHandler) Delete(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete attachments on disk and in DB
|
||||
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||
for _, att := range attachments {
|
||||
_ = h.storage.Delete(att.FilePath)
|
||||
}
|
||||
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
||||
_ = h.stores.Mails.Delete(uint(id))
|
||||
|
||||
// Redirect back based on the folder
|
||||
referer := c.GetHeader("Referer")
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
// MarkRead marks a message as read.
|
||||
func (h *MailHandler) MarkRead(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.stores.Mails.MarkRead(uint(id))
|
||||
|
||||
referer := c.GetHeader("Referer")
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
// DownloadAttachment serves an attachment file for download.
|
||||
func (h *MailHandler) DownloadAttachment(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的附件ID")
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message belongs to the current user
|
||||
msg, err := h.stores.Mails.GetByID(att.MessageID)
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.storage.Read(att.FilePath)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
|
||||
c.Data(http.StatusOK, att.ContentType, data)
|
||||
}
|
||||
|
||||
// getPageParam extracts and validates a page query parameter.
|
||||
// Returns defaultVal if the parameter is missing or invalid.
|
||||
func getPageParam(c *gin.Context, key string, defaultVal int) int {
|
||||
pageStr := c.Query(key)
|
||||
if pageStr == "" {
|
||||
return defaultVal
|
||||
}
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
return defaultVal
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// Drafts renders the drafts folder page.
|
||||
func (h *MailHandler) Drafts(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Settings renders the user settings page.
|
||||
func (h *MailHandler) Settings(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(200, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "",
|
||||
"success": "",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSettings handles the password change form.
|
||||
func (h *MailHandler) UpdateSettings(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
currentUserVal, _ := c.Get("currentUser")
|
||||
currentUser := currentUserVal.(*db.User)
|
||||
|
||||
oldPassword := c.PostForm("old_password")
|
||||
newPassword := c.PostForm("new_password")
|
||||
confirmPassword := c.PostForm("confirm_password")
|
||||
|
||||
// Verify old password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "当前密码不正确",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword == "" {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "新密码不能为空",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "两次输入的密码不一致",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码加密失败",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码更新失败",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "",
|
||||
"success": "密码修改成功",
|
||||
})
|
||||
}
|
||||
|
||||
// resolveActiveFolder maps a folder name to a sidebar active state key.
|
||||
func resolveActiveFolder(folder string) string {
|
||||
switch folder {
|
||||
case "INBOX":
|
||||
return "inbox"
|
||||
case "Sent":
|
||||
return "sent"
|
||||
case "Drafts":
|
||||
return "drafts"
|
||||
default:
|
||||
return folder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminMiddleware checks that the current user has admin privileges.
|
||||
// Must be used after AuthMiddleware so that "currentUser" is available.
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("currentUser")
|
||||
if !exists {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userVal.(*db.User)
|
||||
if !ok || !user.IsAdmin {
|
||||
c.String(http.StatusForbidden, "禁止访问:需要管理员权限")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware checks for a valid session and loads the current user
|
||||
// into the Gin context. If no valid session exists, it redirects to /login.
|
||||
func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("userID")
|
||||
if userID == nil {
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// userID is stored as uint in session, but sessions.Get returns interface{}
|
||||
// which may be stored as int or uint depending on the underlying store.
|
||||
var id uint
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
id = v
|
||||
case int:
|
||||
id = uint(v)
|
||||
case int64:
|
||||
id = uint(v)
|
||||
case float64:
|
||||
id = uint(v)
|
||||
default:
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := stores.Users.GetByID(id)
|
||||
if err != nil {
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("currentUser", user)
|
||||
c.Set("userID", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/web/handlers"
|
||||
"mail_go/internal/web/middleware"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// formatBytes converts a file size in bytes to a human-readable string.
|
||||
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])
|
||||
}
|
||||
|
||||
// WebServer wraps the Gin engine and its dependencies.
|
||||
type WebServer struct {
|
||||
engine *gin.Engine
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.WebConfig
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"div": func(a, b int) int { return a / b },
|
||||
"mod": func(a, b int) int { return a % b },
|
||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||||
"seq": func(n int) []int {
|
||||
result := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
result[i] = i + 1
|
||||
}
|
||||
return result
|
||||
},
|
||||
"domainName": func(domainID uint, domains []interface{}) string {
|
||||
return fmt.Sprintf("Domain #%d", domainID)
|
||||
},
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"formatBytes": func(b int64) string {
|
||||
return formatBytes(b)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
// Session store (cookie-based)
|
||||
cookieStore := cookie.NewStore([]byte("mail-go-secret-key-change-in-production"))
|
||||
cookieStore.Options(sessions.Options{
|
||||
HttpOnly: true,
|
||||
SameSite: 3, // SameSiteLaxMode
|
||||
MaxAge: 86400,
|
||||
Path: "/",
|
||||
})
|
||||
engine.Use(sessions.Sessions("mail_go_session", cookieStore))
|
||||
|
||||
// Load HTML templates with custom functions
|
||||
// Note: Go's filepath.Glob doesn't support **, so we load in two passes
|
||||
tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseGlob("internal/web/templates/*.html"))
|
||||
template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html"))
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
|
||||
ws := &WebServer{
|
||||
engine: engine,
|
||||
stores: stores,
|
||||
storage: attStorage,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
return ws
|
||||
}
|
||||
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores)
|
||||
|
||||
// Public routes (no auth required)
|
||||
ws.engine.GET("/login", authHandler.ShowLogin)
|
||||
ws.engine.POST("/login", authHandler.DoLogin)
|
||||
|
||||
// Auth-protected routes
|
||||
auth := ws.engine.Group("")
|
||||
auth.Use(middleware.AuthMiddleware(ws.stores))
|
||||
{
|
||||
auth.POST("/logout", authHandler.DoLogout)
|
||||
auth.GET("/", func(c *gin.Context) {
|
||||
c.Redirect(302, "/inbox")
|
||||
})
|
||||
|
||||
// Mail routes
|
||||
auth.GET("/inbox", mailHandler.Inbox)
|
||||
auth.GET("/inbox/:id", mailHandler.View)
|
||||
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/read/:id", mailHandler.MarkRead)
|
||||
auth.GET("/attachment/:id", mailHandler.DownloadAttachment)
|
||||
}
|
||||
|
||||
// Admin routes (auth + admin required)
|
||||
admin := ws.engine.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware(ws.stores))
|
||||
admin.Use(middleware.AdminMiddleware())
|
||||
{
|
||||
admin.GET("", adminHandler.Dashboard)
|
||||
admin.GET("/", adminHandler.Dashboard)
|
||||
admin.GET("/domains", adminHandler.ListDomains)
|
||||
admin.GET("/domains/new", adminHandler.NewDomain)
|
||||
admin.POST("/domains", adminHandler.CreateDomain)
|
||||
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
||||
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.GET("/users/new", adminHandler.NewUser)
|
||||
admin.POST("/users", adminHandler.CreateUser)
|
||||
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||||
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
||||
admin.POST("/users/:id", adminHandler.UpdateUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the configured address.
|
||||
func (ws *WebServer) Start() error {
|
||||
return ws.engine.Run(ws.cfg.Addr)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "admin_dashboard"}}
|
||||
<!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" class="active">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:24px;">管理后台</h2>
|
||||
<div style="margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<h3>{{.domainCount}}</h3>
|
||||
<p>域名数</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.userCount}}</h3>
|
||||
<p>用户数</p>
|
||||
</div>
|
||||
</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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin_dns_hint"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DNS配置 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<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>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2>
|
||||
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p>
|
||||
|
||||
<h4 style="margin-bottom:8px;">MX 记录</h4>
|
||||
<div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div>
|
||||
|
||||
<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;">DMARC 记录</h4>
|
||||
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
|
||||
|
||||
<div style="margin-top:24px;">
|
||||
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">返回域名列表</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "admin_domain_form"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<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>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
|
||||
<div class="form-group">
|
||||
<label>域名</label>
|
||||
<input type="text" name="name" required value="{{.domain.Name}}" placeholder="example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SMTP 端口</label>
|
||||
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>IMAP 端口</label>
|
||||
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>POP3 端口</label>
|
||||
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}}>
|
||||
启用 TLS
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,79 @@
|
||||
{{define "admin_domains"}}
|
||||
<!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">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>域名列表</h2>
|
||||
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
||||
</div>
|
||||
{{if not .domains}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无域名</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>域名</th>
|
||||
<th>SMTP</th>
|
||||
<th>IMAP</th>
|
||||
<th>POP3</th>
|
||||
<th>TLS</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .domains}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.SmtpPort}}</td>
|
||||
<td>{{.ImapPort}}</td>
|
||||
<td>{{.Pop3Port}}</td>
|
||||
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
|
||||
<td>
|
||||
<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}} 吗?删除域名将导致关联用户无法收发邮件!');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/domains?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/domains?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{define "admin_user_form"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<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>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑用户{{else}}新增用户{{end}}</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label>
|
||||
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>域名</label>
|
||||
<select name="domain_id" required>
|
||||
<option value="">请选择域名</option>
|
||||
{{range .domains}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>配额 (GB)</label>
|
||||
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
|
||||
管理员
|
||||
</label>
|
||||
</div>
|
||||
{{if .isEdit}}
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
|
||||
启用账户
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button>
|
||||
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{define "admin_users"}}
|
||||
<!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">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users" class="active">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>用户列表</h2>
|
||||
<a href="/admin/users/new" class="btn btn-primary">新增用户</a>
|
||||
</div>
|
||||
{{if not .users}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无用户</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>域名ID</th>
|
||||
<th>配额</th>
|
||||
<th>已用</th>
|
||||
<th>状态</th>
|
||||
<th>管理员</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .users}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.DomainID}}</td>
|
||||
<td>{{formatBytes .QuotaBytes}}</td>
|
||||
<td>{{formatBytes .UsedBytes}}</td>
|
||||
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
|
||||
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
|
||||
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/users?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/users?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,66 @@
|
||||
{{define "styles"}}
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f5f5; color:#333; }
|
||||
.navbar { background:#2c3e50; padding:0 20px; height:50px; display:flex; align-items:center; }
|
||||
.navbar a { color:#ecf0f1; text-decoration:none; margin-right:20px; font-size:14px; }
|
||||
.navbar a:hover { color:#3498db; }
|
||||
.navbar .right { margin-left:auto; }
|
||||
.container { max-width:1200px; margin:20px auto; padding:0 20px; }
|
||||
.card { background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); padding:20px; margin-bottom:20px; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #eee; }
|
||||
th { background:#f8f9fa; font-weight:600; }
|
||||
.btn { display:inline-block; padding:8px 16px; border-radius:4px; text-decoration:none; font-size:14px; cursor:pointer; border:none; }
|
||||
.btn-primary { background:#3498db; color:#fff; }
|
||||
.btn-primary:hover { background:#2980b9; }
|
||||
.btn-danger { background:#e74c3c; color:#fff; }
|
||||
.btn-danger:hover { background:#c0392b; }
|
||||
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||
.alert { padding:12px 16px; border-radius:4px; margin-bottom:16px; }
|
||||
.alert-error { background:#fde8e8; color:#c0392b; }
|
||||
.alert-success { background:#e8fde8; color:#27ae60; }
|
||||
.form-group { margin-bottom:16px; }
|
||||
.form-group label { display:block; margin-bottom:6px; font-weight:600; }
|
||||
.form-group input, .form-group textarea, .form-group select { width:100%; padding:8px 12px; border:1px solid #ddd; border-radius:4px; font-size:14px; }
|
||||
.form-group input[type="checkbox"] { width:auto; }
|
||||
.unread { font-weight:bold; }
|
||||
.message-subject { color:#2c3e50; text-decoration:none; }
|
||||
.message-subject:hover { color:#3498db; }
|
||||
.sidebar { width:200px; float:left; }
|
||||
.sidebar a { display:block; padding:10px 15px; color:#2c3e50; text-decoration:none; border-radius:4px; margin-bottom:2px; }
|
||||
.sidebar a:hover, .sidebar a.active { background:#3498db; color:#fff; }
|
||||
.content { margin-left:220px; }
|
||||
.pagination { margin-top:16px; text-align:center; }
|
||||
.pagination a, .pagination span { display:inline-block; padding:6px 12px; margin:0 2px; border:1px solid #ddd; border-radius:4px; text-decoration:none; color:#333; }
|
||||
.pagination .current { background:#3498db; color:#fff; border-color:#3498db; }
|
||||
.mail-meta { color:#7f8c8d; font-size:13px; margin-bottom:8px; }
|
||||
.mail-body { line-height:1.6; margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||
.attachment-list { margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||
.attachment-item { display:inline-block; margin-right:12px; margin-bottom:8px; padding:6px 12px; background:#ecf0f1; border-radius:4px; font-size:13px; }
|
||||
.attachment-item a { color:#2c3e50; text-decoration:none; }
|
||||
.attachment-item a:hover { color:#3498db; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; }
|
||||
.badge-unread { background:#e74c3c; color:#fff; }
|
||||
.stat-card { display:inline-block; width:200px; padding:20px; margin-right:20px; background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); text-align:center; }
|
||||
.stat-card h3 { font-size:32px; color:#2c3e50; margin-bottom:4px; }
|
||||
.stat-card p { color:#7f8c8d; font-size:14px; }
|
||||
.dns-record { background:#f8f9fa; padding:12px 16px; border-radius:4px; margin-bottom:12px; font-family:monospace; font-size:13px; white-space:pre-wrap; }
|
||||
.clearfix::after { content:""; display:table; clear:both; }
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "navbar"}}
|
||||
{{if .currentUser}}
|
||||
<nav class="navbar">
|
||||
<a href="/inbox">MailGo</a>
|
||||
{{if .currentUser.IsAdmin}}<a href="/admin">管理后台</a>{{end}}
|
||||
<div class="right">
|
||||
<span style="color:#ecf0f1;font-size:13px;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
|
||||
<form method="POST" action="/logout" style="display:inline;">
|
||||
<a href="#" onclick="this.parentElement.submit(); return false;">退出</a>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "compose"}}
|
||||
<!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" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">撰写邮件</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/compose" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label>收件人</label>
|
||||
<input type="email" name="to" required value="{{.to}}" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>抄送(可选)</label>
|
||||
<input type="text" name="cc" value="{{.cc}}" placeholder="cc@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题</label>
|
||||
<input type="text" name="subject" value="{{.subject}}" placeholder="邮件主题">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>正文</label>
|
||||
<textarea name="body" rows="12" placeholder="请输入邮件内容..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>附件</label>
|
||||
<input type="file" name="attachments" multiple>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">发送邮件</button>
|
||||
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
{{define "drafts"}}
|
||||
<!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" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">草稿箱</h2>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无草稿邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">发件人/收件人</th>
|
||||
<th style="width:45%;">主题</th>
|
||||
<th style="width:20%;">时间</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>
|
||||
<a href="/drafts/{{.ID}}" class="message-subject">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/drafts?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/drafts?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,75 @@
|
||||
{{define "inbox"}}
|
||||
<!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" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">收件箱</h2>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">发件人</th>
|
||||
<th style="width:45%;">主题</th>
|
||||
<th style="width:20%;">时间</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr class="{{if not .IsRead}}unread{{end}}">
|
||||
<td>{{.FromAddr}}</td>
|
||||
<td>
|
||||
<a href="/inbox/{{.ID}}" class="message-subject">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
{{if not .IsRead}}
|
||||
<form method="POST" action="/mail/read/{{.ID}}" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">已读</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/inbox?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/inbox?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "login"}}
|
||||
<!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 style="max-width:400px;margin:80px auto;">
|
||||
<div class="card">
|
||||
<h2 style="text-align:center;margin-bottom:24px;">MailGo 登录</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>邮箱地址</label>
|
||||
<input type="email" name="email" required autofocus placeholder="admin@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required placeholder="请输入密码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
{{define "sent"}}
|
||||
<!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" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">发件箱</h2>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无已发送邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">收件人</th>
|
||||
<th style="width:45%;">主题</th>
|
||||
<th style="width:20%;">时间</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>
|
||||
<a href="/sent/{{.ID}}" class="message-subject">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/sent?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/sent?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,48 @@
|
||||
{{define "settings"}}
|
||||
<!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" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">设置</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
|
||||
<h3 style="margin-bottom:12px;">修改密码</h3>
|
||||
<form method="POST" action="/settings">
|
||||
<div class="form-group">
|
||||
<label>当前密码</label>
|
||||
<input type="password" name="old_password" required placeholder="请输入当前密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input type="password" name="new_password" required placeholder="请输入新密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" name="confirm_password" required placeholder="请再次输入新密码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">修改密码</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,64 @@
|
||||
{{define "view"}}
|
||||
<!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" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="margin-bottom:16px;">
|
||||
<a href="javascript:history.back()" class="btn" style="background:#bdc3c7;color:#fff;">返回</a>
|
||||
</div>
|
||||
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2>
|
||||
<div class="mail-meta" style="margin-top:12px;">
|
||||
<p><strong>发件人:</strong> {{.message.FromAddr}}</p>
|
||||
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
|
||||
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
|
||||
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p>
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
{{if .message.HtmlBody}}
|
||||
{{.message.HtmlBody | safeHTML}}
|
||||
{{else}}
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .attachments}}
|
||||
<div class="attachment-list">
|
||||
<h4 style="margin-bottom:8px;">附件</h4>
|
||||
{{range .attachments}}
|
||||
<div class="attachment-item">
|
||||
📎 <a href="/attachment/{{.ID}}">{{.FileName}}</a>
|
||||
<span style="color:#7f8c8d;font-size:12px;">({{formatBytes .FileSize}})</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #eee;">
|
||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger">删除邮件</button>
|
||||
</form>
|
||||
<a href="/compose?to={{.message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary" style="margin-left:8px;">回复</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user