feat: 实现对外邮件投递(外发队列 + MX 直投 + DKIM + 退信 + 管理后台)
- 新增 internal/outbound 模块:MX 查询、SMTP 出站客户端(EHLO/STARTTLS/ MAIL/RCPT/DATA/QUIT)、4xx 临时失败与 5xx 永久失败分类、8BITMIME 支持 - 新增 outbound_messages 队列表与 OutboundStore,后台 worker 指数退避重试 - 永久失败/超限退信到发件人收件箱,包含原因与目标收件人 - 外发邮件使用域名 DKIM 私钥签名(go-msgauth) - SMTP 提交集成:认证用户可发外部收件人,MAIL FROM 必须等于登录用户邮箱, 未认证外部投递明确拒绝(防开放中继) - Web 发信集成:外部收件人自动入队,附件以 multipart/mixed + base64 编码 加入邮件正文 - 每用户每分钟/每日发送限速(max_per_day=0 可禁用外部投递) - 管理后台新增外发队列页面:状态统计、失败原因、手动重试/取消 - 新增 [outbound] 配置段并更新 README / todo.md
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/dkim"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
@@ -22,14 +23,16 @@ import (
|
||||
|
||||
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
||||
type AdminHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
tlsDir string
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
tlsDir string
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores and attachment storage.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir}
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
|
||||
// storage, TLS directory and outbound delivery manager.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, ob *outbound.Manager) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, outbound: ob}
|
||||
}
|
||||
|
||||
// Dashboard renders the admin dashboard with summary statistics.
|
||||
@@ -761,6 +764,93 @@ func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
|
||||
c.Data(http.StatusOK, att.ContentType, data)
|
||||
}
|
||||
|
||||
// ListOutbound renders the outbound delivery queue page.
|
||||
func (h *AdminHandler) ListOutbound(c *gin.Context) {
|
||||
page := getPageParam(c, "page", 1)
|
||||
status := c.Query("status")
|
||||
|
||||
items, total, err := h.stores.Outbound.List(page, 20, status)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载外发队列失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Queue statistics for the summary cards.
|
||||
statCounts := make(map[string]int64)
|
||||
for _, s := range []string{
|
||||
db.OutboundStatusPending,
|
||||
db.OutboundStatusDeferred,
|
||||
db.OutboundStatusSent,
|
||||
db.OutboundStatusFailed,
|
||||
} {
|
||||
n, _ := h.stores.Outbound.CountByStatus(s)
|
||||
statCounts[s] = n
|
||||
}
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_outbound", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"status": status,
|
||||
"statCounts": statCounts,
|
||||
"statusText": outbound.StatusText,
|
||||
"activeFolder": "outbound",
|
||||
})
|
||||
}
|
||||
|
||||
// RetryOutbound resets an outbound queue item for immediate redelivery.
|
||||
func (h *AdminHandler) RetryOutbound(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的队列ID")
|
||||
return
|
||||
}
|
||||
|
||||
if h.outbound == nil {
|
||||
c.String(http.StatusInternalServerError, "外发服务不可用")
|
||||
return
|
||||
}
|
||||
if err := h.outbound.Retry(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "重试失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/outbound")
|
||||
}
|
||||
|
||||
// CancelOutbound cancels a queued outbound message.
|
||||
func (h *AdminHandler) CancelOutbound(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的队列ID")
|
||||
return
|
||||
}
|
||||
|
||||
if h.outbound == nil {
|
||||
c.String(http.StatusInternalServerError, "外发服务不可用")
|
||||
return
|
||||
}
|
||||
if err := h.outbound.Cancel(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "取消失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/outbound")
|
||||
}
|
||||
|
||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||
|
||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||
|
||||
+154
-60
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
@@ -18,15 +20,40 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
|
||||
type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
// pendingAttachment holds an uploaded attachment while the message is built.
|
||||
type pendingAttachment struct {
|
||||
filename string
|
||||
contentType string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// 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}
|
||||
// base64LineWrap encodes data as base64 wrapped at 76 columns (RFC 2045).
|
||||
func base64LineWrap(data []byte) string {
|
||||
enc := base64.StdEncoding.EncodeToString(data)
|
||||
if len(enc) <= 76 {
|
||||
return enc
|
||||
}
|
||||
var sb strings.Builder
|
||||
for len(enc) > 76 {
|
||||
sb.WriteString(enc[:76])
|
||||
sb.WriteString("\r\n")
|
||||
enc = enc[76:]
|
||||
}
|
||||
sb.WriteString(enc)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
|
||||
type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// NewMailHandler creates a new MailHandler with the given stores, attachment
|
||||
// storage and outbound delivery manager.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob}
|
||||
}
|
||||
|
||||
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
|
||||
@@ -161,6 +188,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
|
||||
// Handle attachments and check quota
|
||||
form, multipartErr := c.MultipartForm()
|
||||
attachments := make([]pendingAttachment, 0)
|
||||
if multipartErr == nil {
|
||||
files := form.File["attachments"]
|
||||
if len(files) > 0 {
|
||||
@@ -186,6 +214,32 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Read all attachment files into memory once (used for both the
|
||||
// MIME message body and the stored attachment records).
|
||||
for _, file := range files {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf, readErr := io.ReadAll(f)
|
||||
f.Close()
|
||||
if readErr != 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
|
||||
}
|
||||
|
||||
attachments = append(attachments, pendingAttachment{
|
||||
filename: file.Filename,
|
||||
contentType: contentType,
|
||||
data: buf,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +260,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
// Attachments are wrapped in an outer multipart/mixed container.
|
||||
outerBoundary := ""
|
||||
hasAttachments := len(attachments) > 0
|
||||
if hasAttachments {
|
||||
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
// Build message body with multipart/alternative if HTML is present
|
||||
if htmlBody != "" {
|
||||
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
|
||||
@@ -222,32 +286,83 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Append attachment parts to the multipart/mixed container.
|
||||
for _, att := range attachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", att.contentType, att.filename))
|
||||
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", att.filename))
|
||||
sb.WriteString(base64LineWrap(att.data))
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
if hasAttachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
|
||||
localUsers := make([]*db.User, 0, len(allRecipients))
|
||||
var unsupported []string
|
||||
var externalRecipients []string
|
||||
for _, rcpt := range allRecipients {
|
||||
user, err := h.stores.Users.GetByEmail(rcpt)
|
||||
if err != nil {
|
||||
unsupported = append(unsupported, rcpt)
|
||||
externalRecipients = append(externalRecipients, rcpt)
|
||||
continue
|
||||
}
|
||||
localUsers = append(localUsers, user)
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("暂不支持外部投递: %s", strings.Join(unsupported, ", ")),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
|
||||
// Queue external recipients for outbound delivery first, so that
|
||||
// failures (rate limit, invalid address, disabled outbound) abort
|
||||
// before any local copies are created.
|
||||
if len(externalRecipients) > 0 {
|
||||
ob := h.outbound
|
||||
if ob == nil || !ob.Enabled() {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "外部投递未启用",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, rcpt := range externalRecipients {
|
||||
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(sb.String())); err != nil {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, rcptUser := range localUsers {
|
||||
@@ -312,45 +427,24 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
if multipartErr == 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)
|
||||
// Update user used bytes
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, att.FileSize)
|
||||
// Save attachment records linked to the Sent copy (bytes were already
|
||||
// read during message construction).
|
||||
for _, att := range attachments {
|
||||
relPath, err := h.storage.Save(att.filename, att.data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
attRecord := &db.Attachment{
|
||||
MessageID: msg.ID,
|
||||
FileName: att.filename,
|
||||
FilePath: relPath,
|
||||
ContentType: att.contentType,
|
||||
FileSize: int64(len(att.data)),
|
||||
}
|
||||
_ = h.stores.Attachments.Create(attRecord)
|
||||
// Update user used bytes
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, attRecord.FileSize)
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/sent")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/outbound"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/web/handlers"
|
||||
@@ -44,6 +45,7 @@ type WebServer struct {
|
||||
storageCfg config.StorageConfig
|
||||
authCfg config.AuthConfig
|
||||
banCfg config.BanConfig
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
@@ -82,7 +84,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, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig) *WebServer {
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, ob *outbound.Manager) *WebServer {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
@@ -112,6 +114,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
storageCfg: storageCfg,
|
||||
authCfg: authCfg,
|
||||
banCfg: banCfg,
|
||||
outbound: ob,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
@@ -121,8 +124,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"))
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.outbound)
|
||||
|
||||
// Apply BanMiddleware globally before public routes
|
||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||
@@ -182,6 +185,9 @@ func (ws *WebServer) registerRoutes() {
|
||||
admin.GET("/mails", adminHandler.ListMails)
|
||||
admin.GET("/mails/:id", adminHandler.AdminViewMail)
|
||||
admin.GET("/attachment/:id", adminHandler.AdminDownloadAttachment)
|
||||
admin.GET("/outbound", adminHandler.ListOutbound)
|
||||
admin.POST("/outbound/:id/retry", adminHandler.RetryOutbound)
|
||||
admin.POST("/outbound/:id/cancel", adminHandler.CancelOutbound)
|
||||
admin.GET("/bans", adminHandler.ListBans)
|
||||
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||||
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<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" class="active">所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{{define "admin_outbound"}}
|
||||
<!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/outbound" {{if eq .activeFolder "outbound"}}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>
|
||||
|
||||
<div style="margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "pending"}}</h3>
|
||||
<p>待发送</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "deferred"}}</h3>
|
||||
<p>等待重试</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "sent"}}</h3>
|
||||
<p>已送达</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{index .statCounts "failed"}}</h3>
|
||||
<p>失败</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="margin-bottom:12px;">
|
||||
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">全部</a>
|
||||
<a href="/admin/outbound?status=pending" class="btn btn-sm">待发送</a>
|
||||
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a>
|
||||
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a>
|
||||
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a>
|
||||
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>发件人</th>
|
||||
<th>收件人</th>
|
||||
<th>状态</th>
|
||||
<th>尝试次数</th>
|
||||
<th>下次重试</th>
|
||||
<th>最后响应 / 错误</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .items}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.FromAddr}}</td>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>
|
||||
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span>
|
||||
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{call $.statusText .Status}}</span>
|
||||
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{call $.statusText .Status}}</span>
|
||||
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{call $.statusText .Status}}</span>
|
||||
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{.Attempts}}</td>
|
||||
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td>
|
||||
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
|
||||
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">重试</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if or (eq .Status "pending") (eq .Status "deferred")}}
|
||||
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">取消</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">队列为空</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">上一页</a>{{end}}
|
||||
<span class="current">第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
Reference in New Issue
Block a user