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.