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 " 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("", 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 } }