- 1 个 dispatcher + N 个 worker 池(默认 4)并行投递,启动时立即 扫描清空积压;workers=0/1 退化为串行(旧行为) - 每收件域并发信号量(默认 2,配置 max_concurrent_per_domain), 防对单个 MX 域并发过多被判定滥发;中继模式统一按 relay 限制 - store 新增 Claim 原子抢占(sending 状态防重复投递),并发下 每封邮件恰好投递一次 - 新增配置 workers/batch_size(替代硬编码 50);dispatcher 改为 抢占后投递、worker 只负责发送,退避重试/退信/状态机不变 - 新增测试:真并发、串行回退、每域上限、临时/永久失败、错误隔离、 Claim 并发原子性与状态可抢占性;-race 无警告
517 lines
15 KiB
Go
517 lines
15 KiB
Go
package outbound
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/mail"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"mail_go/config"
|
|
"mail_go/internal/db"
|
|
"mail_go/internal/store"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Manager orchestrates the outbound delivery queue: enqueueing messages,
|
|
// concurrent background delivery workers, exponential backoff retries, DKIM
|
|
// signing, per-user rate limits and failure bounces.
|
|
//
|
|
// 并发模型:一个 dispatcher goroutine 周期性扫描队列并原子抢占(Claim)
|
|
// 待投递项,投递给 worker 池(默认 4 个 goroutine)并行发送;同一收件域
|
|
// (或中继)的连接数受 max_concurrent_per_domain 限制。workers=0/1 时退
|
|
// 化为串行投递(旧行为)。
|
|
type Manager struct {
|
|
cfg config.OutboundConfig
|
|
hostname string // EHLO hostname
|
|
mailer *Mailer
|
|
stores *store.Stores
|
|
|
|
kick chan struct{}
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
once sync.Once
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
lim map[uint]*userWindow
|
|
jobs chan *db.OutboundMessage
|
|
domMu sync.Mutex
|
|
dom map[string]chan struct{} // 每域并发信号量
|
|
|
|
// deliver 执行单封投递,默认走 m.mailer.Deliver;测试可注入替换。
|
|
deliver func(from, to string, data []byte) (string, error)
|
|
}
|
|
|
|
// userWindow tracks a user's sending rate within fixed windows.
|
|
type userWindow struct {
|
|
minuteStart time.Time
|
|
minuteCount int
|
|
dayStart time.Time
|
|
dayCount int
|
|
}
|
|
|
|
// NewManager creates an outbound delivery Manager.
|
|
// hostname is the EHLO name presented to remote servers (defaults to "localhost").
|
|
func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores) *Manager {
|
|
batchSize := cfg.BatchSize
|
|
if batchSize <= 0 {
|
|
batchSize = 50
|
|
}
|
|
workers := cfg.Workers
|
|
if workers <= 1 {
|
|
workers = 1
|
|
}
|
|
m := &Manager{
|
|
cfg: cfg,
|
|
hostname: hostname,
|
|
mailer: NewMailer(hostname, time.Duration(cfg.ConnectTimeout)*time.Second),
|
|
stores: stores,
|
|
kick: make(chan struct{}, 1),
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
lim: make(map[uint]*userWindow),
|
|
jobs: make(chan *db.OutboundMessage, batchSize),
|
|
dom: make(map[string]chan struct{}),
|
|
}
|
|
m.deliver = m.mailer.Deliver
|
|
m.mailer.IPFamily = cfg.IPFamily
|
|
m.mailer.SourceIP = cfg.SourceIP
|
|
if cfg.SourceIP != "" {
|
|
log.Printf("outbound: binding source address %s (ip_family=%s)", cfg.SourceIP, cfg.IPFamily)
|
|
}
|
|
|
|
if cfg.RelayHost != "" {
|
|
m.mailer.Relay = &RelayConfig{
|
|
Host: cfg.RelayHost,
|
|
Port: cfg.RelayPort,
|
|
Username: cfg.RelayUser,
|
|
Password: cfg.RelayPassword,
|
|
StartTLS: cfg.RelayStartTLS,
|
|
TLSInsecure: cfg.RelayTLSInsecure,
|
|
}
|
|
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
|
|
}
|
|
log.Printf("outbound: %d delivery workers, batch=%d, per-domain concurrency=%d",
|
|
workers, batchSize, cfg.MaxConcurrentPerDomain)
|
|
return m
|
|
}
|
|
|
|
// Start launches the dispatcher and the delivery worker pool.
|
|
func (m *Manager) Start() {
|
|
interval := time.Duration(m.cfg.PollInterval) * time.Second
|
|
if interval <= 0 {
|
|
interval = 15 * time.Second
|
|
}
|
|
|
|
// 调度者:启动时立即扫描一次(清空积压),之后按周期扫描 +
|
|
// 原子抢占待投递项,投递给 worker 池。
|
|
m.wg.Add(1)
|
|
go func() {
|
|
defer m.wg.Done()
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
log.Printf("outbound: dispatcher started (interval=%s, max_attempts=%d)", interval, m.cfg.MaxAttempts)
|
|
m.processDue()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
m.processDue()
|
|
case <-m.kick:
|
|
m.processDue()
|
|
case <-m.stop:
|
|
close(m.jobs)
|
|
close(m.done)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Worker 池:并行投递。
|
|
workers := m.cfg.Workers
|
|
if workers <= 1 {
|
|
workers = 1
|
|
}
|
|
for i := 0; i < workers; i++ {
|
|
m.wg.Add(1)
|
|
go func() {
|
|
defer m.wg.Done()
|
|
for job := range m.jobs {
|
|
m.deliverOne(job)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Stop gracefully stops the dispatcher and workers.
|
|
func (m *Manager) Stop() {
|
|
m.once.Do(func() {
|
|
close(m.stop)
|
|
})
|
|
<-m.done
|
|
m.wg.Wait()
|
|
}
|
|
|
|
// kickWorker nudges the worker to scan the queue immediately.
|
|
func (m *Manager) kickWorker() {
|
|
select {
|
|
case m.kick <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Enabled reports whether external delivery is configured on.
|
|
func (m *Manager) Enabled() bool {
|
|
return m.cfg.MaxPerDay > 0
|
|
}
|
|
|
|
// MaxRecipients returns the maximum number of external recipients allowed
|
|
// per message (0 means unlimited).
|
|
func (m *Manager) MaxRecipients() int {
|
|
return m.cfg.MaxRecipients
|
|
}
|
|
|
|
// Enqueue validates a sender/recipient pair, DKIM-signs the message once and
|
|
// stores it in the outbound queue for background delivery. The recipient must
|
|
// NOT be a local address — callers decide local vs external routing.
|
|
// Returns a permanent-style error for invalid input or rate-limit violations.
|
|
func (m *Manager) Enqueue(senderUser *db.User, from, to string, raw []byte) (*db.OutboundMessage, error) {
|
|
if !m.Enabled() {
|
|
return nil, fmt.Errorf("外部投递未启用")
|
|
}
|
|
|
|
to = strings.TrimSpace(to)
|
|
addr, err := mail.ParseAddress(to)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("收件人地址无效: %s", to)
|
|
}
|
|
to = addr.Address
|
|
|
|
at := strings.LastIndex(to, "@")
|
|
if at < 0 || at == len(to)-1 {
|
|
return nil, fmt.Errorf("收件人地址无效: %s", to)
|
|
}
|
|
recipientDom := strings.ToLower(to[at+1:])
|
|
|
|
// Local addresses must never enter the outbound queue.
|
|
if _, err := m.stores.Users.GetByEmail(to); err == nil {
|
|
return nil, fmt.Errorf("收件人 %s 是本地地址,应走本地投递", to)
|
|
}
|
|
|
|
// Rate limiting per sender user.
|
|
if senderUser != nil {
|
|
if err := m.checkRateLimit(senderUser.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// DKIM-sign once with the sender domain's key.
|
|
signed, err := m.signForSender(from, raw)
|
|
if err != nil {
|
|
log.Printf("outbound: DKIM signing failed for %s: %v", from, err)
|
|
signed = raw
|
|
}
|
|
|
|
item := &db.OutboundMessage{
|
|
MessageID: fmt.Sprintf("<%s@outbound>", uuid.New().String()),
|
|
UserID: userIDOrZero(senderUser),
|
|
FromAddr: from,
|
|
ToAddr: to,
|
|
RecipientDom: recipientDom,
|
|
RawData: string(signed),
|
|
Status: db.OutboundStatusPending,
|
|
Attempts: 0,
|
|
NextAttemptAt: time.Now(),
|
|
}
|
|
if err := m.stores.Outbound.Create(item); err != nil {
|
|
return nil, fmt.Errorf("写入外发队列失败: %w", err)
|
|
}
|
|
|
|
log.Printf("outbound: queued %s -> %s (id=%d)", from, to, item.ID)
|
|
m.kickWorker()
|
|
return item, nil
|
|
}
|
|
|
|
func userIDOrZero(u *db.User) uint {
|
|
if u == nil {
|
|
return 0
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
// signForSender looks up the sender domain's DKIM key and signs the message.
|
|
func (m *Manager) signForSender(from string, raw []byte) ([]byte, error) {
|
|
at := strings.LastIndex(from, "@")
|
|
if at < 0 || at == len(from)-1 {
|
|
return raw, fmt.Errorf("无效发件人地址: %s", from)
|
|
}
|
|
domName := strings.ToLower(from[at+1:])
|
|
|
|
domain, err := m.stores.Domains.GetByName(domName)
|
|
if err != nil {
|
|
return raw, nil // domain not managed locally; send unsigned
|
|
}
|
|
return SignDKIM(raw, domain.Name, domain.DkimSelector, domain.DkimPrivateKey)
|
|
}
|
|
|
|
// checkRateLimit enforces per-user minute/day sending limits.
|
|
func (m *Manager) checkRateLimit(userID uint) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
w := m.lim[userID]
|
|
if w == nil || now.Sub(w.dayStart) >= 24*time.Hour {
|
|
w = &userWindow{minuteStart: now, dayStart: now}
|
|
m.lim[userID] = w
|
|
} else if now.Sub(w.minuteStart) >= time.Minute {
|
|
w.minuteStart = now
|
|
w.minuteCount = 0
|
|
}
|
|
|
|
if m.cfg.MaxPerMin > 0 && w.minuteCount >= m.cfg.MaxPerMin {
|
|
log.Printf("outbound: rate limit exceeded (per minute) for user %d", userID)
|
|
return fmt.Errorf("发送频率超限:每分钟最多 %d 封", m.cfg.MaxPerMin)
|
|
}
|
|
if m.cfg.MaxPerDay > 0 && w.dayCount >= m.cfg.MaxPerDay {
|
|
log.Printf("outbound: rate limit exceeded (per day) for user %d", userID)
|
|
return fmt.Errorf("发送频率超限:每日最多 %d 封", m.cfg.MaxPerDay)
|
|
}
|
|
|
|
w.minuteCount++
|
|
w.dayCount++
|
|
return nil
|
|
}
|
|
|
|
// processDue scans the queue and dispatches due items to the worker pool.
|
|
// Each item is atomically claimed (status -> sending) before dispatch so
|
|
// that concurrent workers never deliver the same message twice. When all
|
|
// workers are busy the dispatcher blocks here, naturally throttling claims;
|
|
// remaining due items are picked up on the next scan.
|
|
func (m *Manager) processDue() {
|
|
batchSize := m.cfg.BatchSize
|
|
if batchSize <= 0 {
|
|
batchSize = 50
|
|
}
|
|
items, err := m.stores.Outbound.ListDue(time.Now(), batchSize)
|
|
if err != nil {
|
|
log.Printf("outbound: loading due queue failed: %v", err)
|
|
return
|
|
}
|
|
for i := range items {
|
|
claimed, err := m.stores.Outbound.Claim(items[i].ID)
|
|
if err != nil {
|
|
log.Printf("outbound: claim item %d failed: %v", items[i].ID, err)
|
|
continue
|
|
}
|
|
if !claimed {
|
|
// 已被其他调度周期抢占(并发下应不会发生,防御性跳过)
|
|
continue
|
|
}
|
|
item := items[i]
|
|
item.Status = db.OutboundStatusSending
|
|
m.jobs <- &item
|
|
}
|
|
}
|
|
|
|
// acquireDomain 获取收件域(或中继)的并发信号量,限制对同一目标域同时
|
|
// 打开的 SMTP 连接数。limit <= 0 表示不限制。
|
|
func (m *Manager) acquireDomain(domain string) func() {
|
|
limit := m.cfg.MaxConcurrentPerDomain
|
|
if limit <= 0 {
|
|
return func() {}
|
|
}
|
|
|
|
m.domMu.Lock()
|
|
sem := m.dom[domain]
|
|
if sem == nil {
|
|
sem = make(chan struct{}, limit)
|
|
m.dom[domain] = sem
|
|
}
|
|
m.domMu.Unlock()
|
|
|
|
sem <- struct{}{}
|
|
return func() { <-sem }
|
|
}
|
|
|
|
// deliverKey 返回并发限制使用的目标标识:配置了中继时所有连接都打向同一
|
|
// smarthost,统一按 "relay" 限制;否则按收件域名限制。
|
|
func (m *Manager) deliverKey(to string) string {
|
|
if m.mailer.Relay != nil && m.mailer.Relay.Host != "" {
|
|
return "relay"
|
|
}
|
|
at := strings.LastIndex(to, "@")
|
|
if at < 0 || at == len(to)-1 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(to[at+1:])
|
|
}
|
|
|
|
// deliverOne performs a single delivery attempt for a queue item.
|
|
// 调用前该项已被原子抢占为 sending,此处不再重复置位。
|
|
func (m *Manager) deliverOne(item *db.OutboundMessage) {
|
|
release := m.acquireDomain(item.ToAddr)
|
|
defer release()
|
|
|
|
resp, err := m.deliver(item.FromAddr, item.ToAddr, []byte(item.RawData))
|
|
|
|
now := time.Now()
|
|
item.Attempts++
|
|
|
|
if err == nil {
|
|
item.Status = db.OutboundStatusSent
|
|
item.LastResponse = resp
|
|
item.LastError = ""
|
|
item.CompletedAt = &now
|
|
if saveErr := m.stores.Outbound.Update(item); saveErr != nil {
|
|
log.Printf("outbound: update item %d to sent failed: %v", item.ID, saveErr)
|
|
return
|
|
}
|
|
log.Printf("outbound: delivered %s -> %s (id=%d, attempts=%d)", item.FromAddr, item.ToAddr, item.ID, item.Attempts)
|
|
return
|
|
}
|
|
|
|
var de *DeliveryError
|
|
permanent := false
|
|
if ok := asDeliveryError(err, &de); ok {
|
|
permanent = de.Permanent
|
|
}
|
|
item.LastResponse = ""
|
|
item.LastError = err.Error()
|
|
|
|
if permanent || item.Attempts >= m.cfg.MaxAttempts {
|
|
item.Status = db.OutboundStatusFailed
|
|
item.CompletedAt = &now
|
|
if saveErr := m.stores.Outbound.Update(item); saveErr != nil {
|
|
log.Printf("outbound: update item %d to failed failed: %v", item.ID, saveErr)
|
|
return
|
|
}
|
|
log.Printf("outbound: permanent failure %s -> %s (id=%d): %v", item.FromAddr, item.ToAddr, item.ID, err)
|
|
m.bounce(item, err)
|
|
return
|
|
}
|
|
|
|
// Temporary failure: exponential backoff retry.
|
|
item.Status = db.OutboundStatusDeferred
|
|
backoff := time.Duration(m.cfg.RetryBaseMin) * time.Minute
|
|
backoff <<= (item.Attempts - 1)
|
|
if backoff > 24*time.Hour {
|
|
backoff = 24 * time.Hour
|
|
}
|
|
item.NextAttemptAt = now.Add(backoff)
|
|
if saveErr := m.stores.Outbound.Update(item); saveErr != nil {
|
|
log.Printf("outbound: update item %d to deferred failed: %v", item.ID, saveErr)
|
|
return
|
|
}
|
|
log.Printf("outbound: temporary failure %s -> %s (id=%d, attempt=%d, retry in %s): %v",
|
|
item.FromAddr, item.ToAddr, item.ID, item.Attempts, backoff, err)
|
|
}
|
|
|
|
// bounce delivers a non-delivery notice to the sender's INBOX.
|
|
func (m *Manager) bounce(item *db.OutboundMessage, deliveryErr error) {
|
|
sender, err := m.stores.Users.GetByEmail(item.FromAddr)
|
|
if err != nil {
|
|
log.Printf("outbound: cannot bounce %s: sender is not a local user", item.FromAddr)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
postmaster := "Mail Delivery System <postmaster@" + m.hostname + ">"
|
|
subject := fmt.Sprintf("邮件投递失败: %s", item.ToAddr)
|
|
|
|
body := "这是一封系统退信通知。\r\n\r\n" +
|
|
fmt.Sprintf("您的邮件未能投递到以下收件人:\r\n\r\n 收件人:%s\r\n 失败原因:%s\r\n 投递时间:%s\r\n 尝试次数:%d\r\n\r\n",
|
|
item.ToAddr, deliveryErr.Error(), now.Format("2006-01-02 15:04:05"), item.Attempts) +
|
|
"如果收件人地址无误,请稍后重试;连续失败可能表示收件地址不存在或对方服务器拒收。\r\n"
|
|
|
|
msg := &db.Message{
|
|
UserID: sender.ID,
|
|
MessageID: fmt.Sprintf("<bounce-%d@%s>", item.ID, m.hostname),
|
|
Folder: "INBOX",
|
|
FromAddr: postmaster,
|
|
ToAddr: item.FromAddr,
|
|
Subject: subject,
|
|
TextBody: body,
|
|
Date: now,
|
|
IsRead: false,
|
|
}
|
|
if err := m.stores.Mails.Create(msg); err != nil {
|
|
log.Printf("outbound: bounce message creation failed: %v", err)
|
|
return
|
|
}
|
|
log.Printf("outbound: bounce delivered to %s for failed delivery of %s", item.FromAddr, item.ToAddr)
|
|
}
|
|
|
|
// Retry resets a failed/deferred queue item for immediate redelivery.
|
|
func (m *Manager) Retry(id uint) error {
|
|
item, err := m.stores.Outbound.GetByID(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item.Status = db.OutboundStatusPending
|
|
item.Attempts = 0
|
|
item.LastError = ""
|
|
item.LastResponse = ""
|
|
item.CompletedAt = nil
|
|
item.NextAttemptAt = time.Now()
|
|
if err := m.stores.Outbound.Update(item); err != nil {
|
|
return err
|
|
}
|
|
m.kickWorker()
|
|
return nil
|
|
}
|
|
|
|
// Cancel marks a queue item as canceled by the administrator.
|
|
func (m *Manager) Cancel(id uint) error {
|
|
item, err := m.stores.Outbound.GetByID(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.Status == db.OutboundStatusSent {
|
|
return fmt.Errorf("已送达的邮件无法取消")
|
|
}
|
|
now := time.Now()
|
|
item.Status = db.OutboundStatusCanceled
|
|
item.LastError = "管理员取消"
|
|
item.CompletedAt = &now
|
|
return m.stores.Outbound.Update(item)
|
|
}
|
|
|
|
// asDeliveryError extracts a *DeliveryError from an error chain.
|
|
func asDeliveryError(err error, target **DeliveryError) bool {
|
|
for err != nil {
|
|
if de, ok := err.(*DeliveryError); ok {
|
|
*target = de
|
|
return true
|
|
}
|
|
type unwrapper interface{ Unwrap() error }
|
|
u, ok := err.(unwrapper)
|
|
if !ok {
|
|
return false
|
|
}
|
|
err = u.Unwrap()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// StatusText returns a human-readable Chinese label for a queue status.
|
|
func StatusText(status string) string {
|
|
switch status {
|
|
case db.OutboundStatusPending:
|
|
return "待发送"
|
|
case db.OutboundStatusSending:
|
|
return "发送中"
|
|
case db.OutboundStatusSent:
|
|
return "已送达"
|
|
case db.OutboundStatusDeferred:
|
|
return "等待重试"
|
|
case db.OutboundStatusFailed:
|
|
return "失败"
|
|
case db.OutboundStatusCanceled:
|
|
return "已取消"
|
|
default:
|
|
return status
|
|
}
|
|
}
|