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:
dsh
2026-08-15 17:18:54 -04:00
parent 0545a71ba2
commit 5eb9bc2c71
28 changed files with 1657 additions and 186 deletions
+96 -6
View File
@@ -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
View File
@@ -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")
+9 -3
View File
@@ -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)
+1
View File
@@ -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">
+1
View File
@@ -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">
+118
View File
@@ -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">
+1
View File
@@ -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">