Smail/backend/services/outbox/runtime.go
smail 866504d7a0
Some checks are pending
Security Checks / security (push) Waiting to run
统一SMTP事务入队并复核投递授权与邮件日期
将SMTP信封收件人与授权快照持久保存,按收件人投递并在执行前复核凭据状态;保留原邮件日期,新增队列元数据迁移并补齐相关回归用例源码。
2026-09-27 23:14:02 +08:00

240 lines
6.7 KiB
Go

package outbox
import (
stdcontext "context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/dbmigration"
"git.pcux.cn/xuans/Smail/services/notification"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/send"
"git.pcux.cn/xuans/Smail/utils/smtp"
"git.pcux.cn/xuans/Smail/utils/validation"
log "github.com/sirupsen/logrus"
"xorm.io/xorm"
)
type Runtime struct {
engine *xorm.Engine
stop chan struct{}
done chan struct{}
once sync.Once
deliver func(*context.Context, *parsemail.Email, string) error
pending *pendingResult
}
type pendingResult struct {
job models.OutboundDelivery
ctx *context.Context
message *parsemail.Email
}
func Start(engine *xorm.Engine) (*Runtime, error) {
if err := recoverInterrupted(engine); err != nil {
return nil, err
}
r := &Runtime{engine: engine, stop: make(chan struct{}), done: make(chan struct{}), deliver: send.SendRecipientTriggered}
go r.loop()
return r, nil
}
func (r *Runtime) Stop(ctx stdcontext.Context) error {
if r == nil {
return nil
}
r.once.Do(func() { close(r.stop) })
select {
case <-r.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (r *Runtime) loop() {
defer close(r.done)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-r.stop:
return
default:
}
if err := r.tick(); err != nil {
log.WithError(err).Error("处理持久投递队列失败")
}
select {
case <-r.stop:
return
case <-ticker.C:
}
}
}
func (r *Runtime) tick() error {
// 单工作器最多保留一个未落库结果,恢复前不领取新任务,避免内存积压和重复投递。
if r.pending != nil {
return dbmigration.WhileWritable(r.flushResult)
}
var jobs []models.OutboundDelivery
if err := r.engine.Where("state = ? AND next_attempt <= ?", queued, time.Now().Unix()).Asc("id").Limit(20).Find(&jobs); err != nil {
return err
}
for _, job := range jobs {
select {
case <-r.stop:
return nil
default:
}
if err := r.process(job); err != nil {
return fmt.Errorf("投递任务 %d: %w", job.ID, err)
}
}
return nil
}
func (r *Runtime) process(job models.OutboundDelivery) error {
// 迁移必须等待 SMTP 及结果落库结束,冻结源库后不再领取任务。
return dbmigration.WhileWritable(func() error { return r.processWritable(job) })
}
func (r *Runtime) processWritable(job models.OutboundDelivery) error {
if r.pending != nil {
return r.flushResult()
}
// 读取失败时尚未开始投递,保留排队状态供数据库恢复后继续处理。
var stored models.Email
emailFound, err := r.engine.ID(job.EmailID).Get(&stored)
if err != nil {
return err
}
var user models.User
userFound := false
if emailFound {
userFound, err = r.engine.ID(stored.SendUserID).Get(&user)
if err != nil {
return err
}
}
var submission Submission
authorized := true
if job.Submission != "" {
authorized = userFound && json.Unmarshal([]byte(job.Submission), &submission) == nil
if authorized {
authorized, err = submission.Authorization.Check(r.engine, user)
if err != nil {
return err
}
}
}
owned, err := claim(r.engine, &job)
if err != nil || !owned {
return err
}
ctx := &context.Context{Context: stdcontext.Background()}
var message *parsemail.Email
deliveryErr := func() (result error) {
defer func() {
if value := recover(); value != nil {
result = &smtp.DeliveryUncertainError{Cause: fmt.Errorf("投递任务异常: %v", value)}
}
}()
if !emailFound {
return errors.New("待投递邮件已不存在")
}
if !userFound || user.Disabled != 0 {
return errors.New("发件账号不可用,已停止投递")
}
ctx.UserID, ctx.UserAccount, ctx.UserName, ctx.Role = user.ID, user.Account, user.Name, user.EffectiveRole()
message, err = parsemail.NewEmailFromModel(stored)
if err != nil {
return err
}
if job.Submission != "" {
if !authorized || !authorizedQueuedSender(submission, message, user) {
return notification.ErrDenied
}
message.EnvelopeFrom = submission.EnvelopeFrom
if submission.Date != "" {
message.Date = submission.Date
}
message.EnvelopeTo = []*parsemail.User{{EmailAddress: job.Recipient}}
message.DeliveryTrigger = submission.Trigger
}
return r.deliver(ctx, message, job.Recipient)
}()
// 固定首次结果及退避时间,后续只重试落库,绝不再次调用 SMTP。
r.pending = &pendingResult{job: deliveryResult(job, deliveryErr), ctx: ctx, message: message}
return r.flushResult()
}
func authorizedQueuedSender(submission Submission, message *parsemail.Email, user models.User) bool {
if message == nil || message.From == nil || message.Sender == nil || submission.Trigger == "" {
return false
}
domains := config.CurrentMailDomains()
if submission.Authorization.Kind == "notification" {
sender := validation.NotificationAddress(domains.Primary)
return sender != "" && strings.EqualFold(submission.EnvelopeFrom, sender) &&
strings.EqualFold(message.From.EmailAddress, sender) && strings.EqualFold(message.Sender.EmailAddress, sender)
}
allowAlias := user.EffectiveRole() == access.RoleSuperAdmin
return validation.IsAuthorizedSender(message.From.EmailAddress, user.Account, allowAlias, domains.Accepted) &&
validation.IsAuthorizedSender(message.Sender.EmailAddress, user.Account, allowAlias, domains.Accepted) &&
(submission.EnvelopeFrom == "" || validation.IsAuthorizedSender(submission.EnvelopeFrom, user.Account, allowAlias, domains.Accepted))
}
func (r *Runtime) flushResult() error {
result := r.pending
if result == nil {
return nil
}
terminal, err := persistResult(r.engine, result.job)
if err != nil {
return fmt.Errorf("投递任务 %d 结果写入失败,已暂停领取任务并等待数据库恢复: %w", result.job.ID, err)
}
r.pending = nil
if terminal && result.message != nil {
r.notify(result.ctx, result.message, result.job.EmailID)
}
return nil
}
func (r *Runtime) notify(ctx *context.Context, message *parsemail.Email, emailID int) {
var jobs []models.OutboundDelivery
if err := r.engine.Where("email_id = ?", emailID).Find(&jobs); err != nil {
log.WithError(err).Error("读取投递结果用于插件通知失败")
return
}
failures := make(map[string]error)
for _, job := range jobs {
if job.State != accepted {
failures[job.Recipient] = errors.New(job.LastError)
}
}
for _, hook := range hooks.Snapshot() {
if hook == nil {
continue
}
func() {
defer func() {
if recover() != nil {
log.Error("投递完成插件发生异常")
}
}()
hook.SendAfter(ctx, message, failures)
}()
}
}