统一SMTP事务入队并复核投递授权与邮件日期
Some checks are pending
Security Checks / security (push) Waiting to run

将SMTP信封收件人与授权快照持久保存,按收件人投递并在执行前复核凭据状态;保留原邮件日期,新增队列元数据迁移并补齐相关回归用例源码。
This commit is contained in:
smail 2026-09-27 23:14:02 +08:00
parent 29eb85b4e6
commit 866504d7a0
16 changed files with 633 additions and 375 deletions

View File

@ -1,5 +1,11 @@
package migration
import "context"
func applyOutboundSubmissionSchema(ctx context.Context, exec executor, dialect Dialect) error {
return ensureColumn(ctx, exec, dialect, "outbound_delivery", "submission", "TEXT NULL")
}
func outboundDeliveryStatements() map[Dialect][]string {
return map[Dialect][]string{
DialectSQLite: {

View File

@ -44,6 +44,7 @@ func DefaultMigrations() []Migration {
DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema),
DefineCustom(28, "expand_recovery_code_ciphertext", "expand_recovery_code_ciphertext_v1", applyRecoveryCodeCiphertextSchema),
DefineCustom(29, "expand_email_thread", "expand_email_thread_v1", applyEmailThreadSchema),
DefineCustom(30, "expand_outbound_submission", "expand_outbound_submission_v1", applyOutboundSubmissionSchema),
}
}

View File

@ -298,6 +298,12 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
22: "1047cb22f5a32c8a09ba4ccedff8713a5225c14fdb98357db86a6e0723c30d8b",
23: "5d80f2c2c583fc9a033fc320c0852c73bd6701ce6c8f12e8904deedfe0ba44b2",
24: "36533c34245853580f0e1c67ebb3a4eaeb8dafc8df2f3a85acff6b15d0ea4a6b",
25: "86ea27d3323ef4e814dabca3e7ab55dd6f19c8ee4690cde90974751686c9b240",
26: "f98d150fa43d09903ed954922495ddef8dc29fc3cf2d2229583bf0d2bce3f400",
27: "9bdd98fc533e71f808869ae3ee9829f59702409f82030a6466c31254e08875af",
28: "dd1698fc6fa0762ee6399324fec75e839fdb79aa93413acd547e573fb740c654",
29: "5f027d90d556d50b236d7cfb48fbf7821041e46b8c8968563b4a4ebffde7ff3a",
30: "392621bda08c66f6e8494bba1773f3c9ca9be6591f21afb094f2fcc3beaecfa8",
}
migrations := DefaultMigrations()
if len(migrations) != len(expected) {

View File

@ -77,6 +77,7 @@ type Email struct {
ThreadReferences string
EnvelopeFrom string
EnvelopeTo []*User `json:"-"`
DeliveryTrigger string `json:"-"`
ReplyTo []*User
From *User
To []*User

View File

@ -1,20 +1,16 @@
package smtp_server
import (
"database/sql"
stderrors "errors"
"fmt"
"strings"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/consts"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/outbox"
"git.pcux.cn/xuans/Smail/utils/array"
"git.pcux.cn/xuans/Smail/utils/async"
"git.pcux.cn/xuans/Smail/utils/context"
smtp "github.com/emersion/go-smtp"
log "github.com/sirupsen/logrus"
@ -24,84 +20,27 @@ import (
const deliveryPendingError = "delivery result pending; outcome unknown after interruption"
type outgoingSender func(*context.Context, *parsemail.Email) (error, map[string]error)
type auditEnqueuer func(*xorm.Session, models.Email) error
func (s *Session) deliverOutgoingEmail(email *parsemail.Email, sender outgoingSender) error {
func (s *Session) deliverOutgoingEmail(email *parsemail.Email, trigger string) error {
if err := validateOutgoingEmail(email); err != nil {
return err
}
ctx := s.Ctx
_, saved, err := saveEmail(ctx, email.Size, email, ctx.UserID, int(consts.EmailTypeSend), nil, true, true)
authorization, err := s.authorization.QueueAuthorization()
if err != nil || s.Ctx == nil || authorization.UserID != s.Ctx.UserID {
return &smtp.SMTPError{Code: 535, EnhancedCode: smtp.EnhancedCode{5, 7, 8}, Message: "Authentication credentials revoked"}
}
submission := outbox.Submission{
EnvelopeFrom: s.From, Date: email.Date, Trigger: trigger, Authorization: authorization,
}
enqueue := func(session *xorm.Session, saved models.Email) error {
return outbox.EnqueueSMTP(session, saved, s.To, submission)
}
_, _, err = saveEmail(s.Ctx, email.Size, email, s.Ctx.UserID, int(consts.EmailTypeSend), nil, true, true, enqueue)
if err != nil {
log.WithContext(ctx).Errorf("CRITICAL audit_event=initial_persist_failed direction=outbound msg_id=%q error=%v", email.MsgID, err)
log.WithContext(s.Ctx).Errorf("CRITICAL audit_event=submission_enqueue_failed direction=outbound msg_id=%q error=%v", email.MsgID, err)
return localPersistenceSMTPError()
}
deliveryErr, domainErrors := sender(ctx, email)
runSendAfterHooks(ctx, email, domainErrors)
targetStatus := consts.EmailStatusSent
var errorText any
if deliveryErr != nil {
targetStatus = consts.EmailStatusFail
errorText = deliveryErr.Error()
}
if err = persistDeliveryResult(ctx, saved.Id, targetStatus, errorText); err != nil {
log.WithContext(ctx).Errorf(
"CRITICAL audit_event=delivery_result_persist_failed email_id=%d msg_id=%q target_status=%d downstream_accepted=%t error=%v",
saved.Id, saved.MsgID, targetStatus, deliveryErr == nil, err,
)
}
// If the remote MX accepted DATA, returning a local 451 here would cause a
// duplicate delivery. The persistence failure is retained as pending state
// and a CRITICAL log; the SMTP result must still reflect the remote result.
return downstreamDeliverySMTPError(deliveryErr, domainErrors)
}
func runSendAfterHooks(ctx *context.Context, email *parsemail.Email, domainErrors map[string]error) {
process := async.New(ctx)
for _, hook := range hooks.Snapshot() {
if hook == nil {
continue
}
process.WaitProcess(func(value any) {
value.(framework.EmailHook).SendAfter(ctx, email, domainErrors)
}, hook)
}
process.Wait()
}
func persistDeliveryResult(ctx *context.Context, emailID int, status int8, errorText any) error {
return db.Transaction(db.Instance, func(session *xorm.Session) error {
result, err := session.Exec(db.WithContext(ctx, `update email
set status=?, error=?, update_time=CURRENT_TIMESTAMP
where id=?`), status, errorText, emailID)
if err != nil {
return err
}
if err = requireOneAffected(result, "update email delivery result"); err != nil {
return err
}
result, err = session.Exec(db.WithContext(ctx, `update user_email
set status=? where email_id=?`), status, emailID)
if err != nil {
return err
}
return requireOneAffected(result, "update user_email delivery result")
})
}
func requireOneAffected(result sql.Result, operation string) error {
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("%s: read affected rows: %w", operation, err)
}
if affected != 1 {
return fmt.Errorf("%s: affected %d rows, want 1", operation, affected)
}
return nil
}
@ -113,7 +52,7 @@ func localPersistenceSMTPError() error {
}
}
func persistInitialAudit(ctx *context.Context, modelEmail models.Email, userIDs []int) (*models.Email, error) {
func persistInitialAudit(ctx *context.Context, modelEmail models.Email, userIDs []int, enqueue ...auditEnqueuer) (*models.Email, error) {
var saved models.Email
err := db.Transaction(db.Instance, func(session *xorm.Session) error {
candidate := modelEmail
@ -130,6 +69,11 @@ func persistInitialAudit(ctx *context.Context, modelEmail models.Email, userIDs
return err
}
}
for _, persist := range enqueue {
if err := persist(session, candidate); err != nil {
return err
}
}
saved = candidate
return nil
})

View File

@ -1,15 +1,12 @@
package smtp_server
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"
"net/textproto"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@ -21,49 +18,129 @@ import (
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/credentials"
listservice "git.pcux.cn/xuans/Smail/services/list"
"git.pcux.cn/xuans/Smail/services/outbox"
"git.pcux.cn/xuans/Smail/utils/context"
smtp "github.com/emersion/go-smtp"
log "github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
func TestOutgoingAuditInsertFailureDoesNotDeliver(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
blocker := beginAuditReadTransaction(t, dsn)
var deliveries atomic.Int32
session := newAuditTestSession()
err := session.deliverOutgoingEmail(newAuditTestEmail("insert-busy@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
return nil, nil
})
assertSMTPCode(t, err, 451)
if got := deliveries.Load(); got != 0 {
t.Fatalf("downstream deliveries = %d, want 0", got)
func newAuthorizedAuditSession(t *testing.T, engine *xorm.Engine) (*Session, string) {
t.Helper()
user := models.User{ID: 1, Account: "sender", Password: "test-hash", Role: "user", SessionVersion: 1, SecurityVersion: 1}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
assertAuditCounts(t, engine, 0, 0)
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
if err := service.SetProtocolEnabled(user.ID, "smtp", true); err != nil {
t.Fatal(err)
}
code, err := service.GenerateProtocolCode(user.ID, "smtp")
if err != nil {
t.Fatal(err)
}
_, grant, err := auth.AuthenticateProtocolSession("smtp", "192.0.2.93:1234", "sender@example.com", code)
if err != nil {
t.Fatal(err)
}
session := newAuditTestSession()
session.authorization = grant
session.From = "sender@example.com"
session.To = []string{"recipient@example.net"}
return session, code
}
func auditQueue(t *testing.T, engine *xorm.Engine) []models.OutboundDelivery {
t.Helper()
var jobs []models.OutboundDelivery
if err := engine.Asc("id").Find(&jobs); err != nil {
t.Fatal(err)
}
return jobs
}
func TestSMTPSubmissionQueuesOnlyAcceptedEnvelope(t *testing.T) {
engine, _ := newAuditTestEngine(t)
session, code := newAuthorizedAuditSession(t, engine)
session.From = "sender@EXAMPLE.com"
session.To = []string{"envelope@example.net", "envelope@EXAMPLE.net"}
email := newAuditTestEmail("envelope-queue@example.com")
email.Date = "2026-09-27T12:30:00+08:00"
if err := session.deliverOutgoingEmail(email, "SMTP"); err != nil {
t.Fatal(err)
}
jobs := auditQueue(t, engine)
if len(jobs) != 1 || jobs[0].Recipient != "envelope@example.net" || jobs[0].State != "queued" || jobs[0].Attempts != 0 {
t.Fatalf("queued envelope jobs = %+v", jobs)
}
var submission outbox.Submission
if err := json.Unmarshal([]byte(jobs[0].Submission), &submission); err != nil {
t.Fatal(err)
}
if submission.EnvelopeFrom != session.From || submission.Date != email.Date || submission.Authorization.Kind != "smtp" || submission.Trigger != "SMTP" {
t.Fatal("SMTP envelope or authorization metadata was not preserved")
}
if strings.Contains(jobs[0].Submission, code) || strings.Contains(jobs[0].Submission, "test-hash") {
t.Fatal("submission persisted reusable credentials")
}
if email.To[0].EmailAddress != "recipient@example.net" || email.MessageId <= 0 {
t.Fatal("queue changed message headers or lost stored identity")
}
var saved models.Email
if found, err := engine.ID(jobs[0].EmailID).Get(&saved); err != nil || !found {
t.Fatalf("saved message: found=%v err=%v", found, err)
}
expectedDate, err := time.Parse(time.RFC3339, email.Date)
if err != nil || !saved.SendDate.Equal(expectedDate) {
t.Fatalf("stored message date = %v, want %v", saved.SendDate, expectedDate)
}
assertAuditCounts(t, engine, 1, 1)
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
}
func TestSMTPQueueInsertFailureRollsBackWholeSubmission(t *testing.T) {
engine, _ := newAuditTestEngine(t)
session, _ := newAuthorizedAuditSession(t, engine)
if _, err := engine.Exec(`CREATE TRIGGER fail_queue_insert BEFORE INSERT ON outbound_delivery
BEGIN SELECT RAISE(ABORT, 'forced queue insert failure'); END`); err != nil {
t.Fatal(err)
}
email := newAuditTestEmail("queue-failure@example.com")
assertSMTPCode(t, session.deliverOutgoingEmail(email, "SMTP"), 451)
assertAuditCounts(t, engine, 0, 0)
if len(auditQueue(t, engine)) != 0 || email.MessageId != 0 {
t.Fatal("failed submission left partial durable state")
}
}
func TestSMTPQueueBusyFailureDoesNotAcceptSubmission(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
session, _ := newAuthorizedAuditSession(t, engine)
blocker := beginAuditReadTransaction(t, dsn)
assertSMTPCode(t, session.deliverOutgoingEmail(newAuditTestEmail("busy@example.com"), "SMTP"), 451)
assertAuditCounts(t, engine, 0, 0)
if len(auditQueue(t, engine)) != 0 {
t.Fatal("failed transaction left delivery tasks")
}
if err := blocker.Rollback(); err != nil {
t.Fatal(err)
}
if err := session.deliverOutgoingEmail(newAuditTestEmail("insert-busy@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
return nil, nil
}); err != nil {
t.Fatalf("delivery after lock release failed: %v", err)
}
if got := deliveries.Load(); got != 1 {
t.Fatalf("downstream deliveries after recovery = %d, want 1", got)
if err := session.deliverOutgoingEmail(newAuditTestEmail("busy@example.com"), "SMTP"); err != nil {
t.Fatal(err)
}
assertAuditCounts(t, engine, 1, 1)
assertAuditStatuses(t, engine, consts.EmailStatusSent)
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
}
func TestOutgoingAuditRetriesBusyInitialInsertBeforeDeliver(t *testing.T) {
func TestSMTPQueueRetriesInitialTransactionBeforeAccepting(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
session, _ := newAuthorizedAuditSession(t, engine)
blocker := beginAuditReadTransaction(t, dsn)
released := make(chan struct{})
go func() {
@ -71,21 +148,75 @@ func TestOutgoingAuditRetriesBusyInitialInsertBeforeDeliver(t *testing.T) {
_ = blocker.Rollback()
close(released)
}()
var deliveries atomic.Int32
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("initial-retry@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
return nil, nil
})
err := session.deliverOutgoingEmail(newAuditTestEmail("busy-retry@example.com"), "SMTP")
<-released
if err != nil {
t.Fatalf("deliverOutgoingEmail() error = %v", err)
}
if got := deliveries.Load(); got != 1 {
t.Fatalf("downstream deliveries = %d, want 1", got)
t.Fatal(err)
}
assertAuditCounts(t, engine, 1, 1)
assertAuditStatuses(t, engine, consts.EmailStatusSent)
if len(auditQueue(t, engine)) != 1 {
t.Fatal("transaction retry duplicated delivery tasks")
}
}
func TestSMTPQueueRequiresCurrentAuthorization(t *testing.T) {
for _, revoke := range []bool{false, true} {
t.Run(fmt.Sprintf("revoke=%t", revoke), func(t *testing.T) {
engine, _ := newAuditTestEngine(t)
session := newAuditTestSession()
if revoke {
session, _ = newAuthorizedAuditSession(t, engine)
if _, err := engine.Where("user_id = ?", 1).Cols("enabled").Update(&models.ProtocolCredential{Enabled: false}); err != nil {
t.Fatal(err)
}
}
assertSMTPCode(t, session.deliverOutgoingEmail(newAuditTestEmail("revoked@example.com"), "SMTP"), 535)
assertAuditCounts(t, engine, 0, 0)
if len(auditQueue(t, engine)) != 0 {
t.Fatal("unauthorized submission was queued")
}
})
}
}
func TestSameMessageIDCreatesIndependentQueuedSubmissions(t *testing.T) {
engine, _ := newAuditTestEngine(t)
session, _ := newAuthorizedAuditSession(t, engine)
for i := 0; i < 2; i++ {
email := newAuditTestEmail("reused-message-id@example.com")
email.Text = []byte(fmt.Sprintf("body %d", i))
if err := session.deliverOutgoingEmail(email, "SMTP"); err != nil {
t.Fatal(err)
}
}
assertAuditCounts(t, engine, 2, 2)
assertMessageIDCount(t, engine, "reused-message-id@example.com", 2)
if len(auditQueue(t, engine)) != 2 {
t.Fatal("independent submissions were merged by Message-ID")
}
}
func TestSMTPAcceptanceDoesNotInvokeDeliveryCompletionHooks(t *testing.T) {
engine, _ := newAuditTestEngine(t)
session, _ := newAuthorizedAuditSession(t, engine)
hook := &queuedAuditHook{authenticationCaptureHook: &authenticationCaptureHook{}}
hooks.HookList = map[string]framework.EmailHook{"queued": hook}
if err := session.deliverOutgoingEmail(newAuditTestEmail("deferred-hook@example.com"), "SMTP"); err != nil {
t.Fatal(err)
}
if hook.called {
t.Fatal("SendAfter was called before the queue delivered the message")
}
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
}
type queuedAuditHook struct {
*authenticationCaptureHook
called bool
}
func (h *queuedAuditHook) SendAfter(*context.Context, *parsemail.Email, map[string]error) {
h.called = true
}
func TestOutgoingAuditRelationInsertRollsBackEmail(t *testing.T) {
@ -139,237 +270,6 @@ func TestIncomingAuditRelationInsertRollsBackEmail(t *testing.T) {
assertAuditCounts(t, engine, 0, 0)
}
func TestOutgoingAuditRetriesBusyFinalUpdateAtomically(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
var blocker *sql.Tx
session := newAuditTestSession()
err := session.deliverOutgoingEmail(newAuditTestEmail("final-retry@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
blocker = beginAuditReadTransaction(t, dsn)
go func() {
time.Sleep(35 * time.Millisecond)
_ = blocker.Rollback()
}()
return nil, nil
})
if err != nil {
t.Fatalf("deliverOutgoingEmail() error = %v", err)
}
assertAuditStatuses(t, engine, consts.EmailStatusSent)
assertAuditError(t, engine, "", false)
}
func TestOutgoingAuditFinalUpdateIsAtomic(t *testing.T) {
engine, _ := newAuditTestEngine(t)
if _, err := engine.Exec(fmt.Sprintf(`CREATE TRIGGER fail_user_email_success
BEFORE UPDATE OF status ON user_email
WHEN NEW.status = %d
BEGIN
SELECT RAISE(ABORT, 'forced user_email update failure');
END`, consts.EmailStatusSent)); err != nil {
t.Fatal(err)
}
session := newAuditTestSession()
err := session.deliverOutgoingEmail(newAuditTestEmail("atomic-update@example.com"), successfulAuditSender)
if err != nil {
t.Fatalf("accepted delivery must remain successful, got %v", err)
}
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
assertAuditError(t, engine, deliveryPendingError, true)
}
func TestAcceptedDeliveryWithPermanentAuditLockReturnsSuccessAndAlerts(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
var blocker *sql.Tx
var deliveries atomic.Int32
oldOutput := log.StandardLogger().Out
oldLevel := log.GetLevel()
var logs bytes.Buffer
log.SetOutput(&logs)
log.SetLevel(log.ErrorLevel)
t.Cleanup(func() {
log.SetOutput(oldOutput)
log.SetLevel(oldLevel)
})
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("accepted-unknown@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
blocker = beginAuditReadTransaction(t, dsn)
return nil, nil
})
if err != nil {
t.Fatalf("accepted delivery must remain successful, got %v", err)
}
if got := deliveries.Load(); got != 1 {
t.Fatalf("downstream deliveries = %d, want 1", got)
}
if !strings.Contains(logs.String(), "audit_event=delivery_result_persist_failed") {
t.Fatalf("missing CRITICAL persistence alert in logs: %s", logs.String())
}
if err = blocker.Rollback(); err != nil {
t.Fatal(err)
}
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
assertAuditError(t, engine, deliveryPendingError, true)
}
func TestTemporaryDeliveryFailureWithUnpersistedResultCanRetryAsNewAttempt(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
var blocker *sql.Tx
var deliveries atomic.Int32
session := newAuditTestSession()
messageID := "temporary-unpersisted@example.com"
err := session.deliverOutgoingEmail(newAuditTestEmail(messageID), func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
blocker = beginAuditReadTransaction(t, dsn)
return errors.New("delivery failed"), map[string]error{
"example.net": &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")},
}
})
assertSMTPCode(t, err, 451)
if err = blocker.Rollback(); err != nil {
t.Fatal(err)
}
if err = session.deliverOutgoingEmail(newAuditTestEmail(messageID), func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
return nil, nil
}); err != nil {
t.Fatalf("retry after temporary failure: %v", err)
}
if got := deliveries.Load(); got != 2 {
t.Fatalf("downstream deliveries = %d, want 2", got)
}
assertAuditCounts(t, engine, 2, 2)
assertMessageIDCount(t, engine, messageID, 2)
}
func TestOutgoingAuditPersistsDeliveryFailure(t *testing.T) {
tests := []struct {
name string
domainErr error
wantCode int
}{
{
name: "temporary",
domainErr: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")},
wantCode: 451,
},
{
name: "permanent",
domainErr: &textproto.Error{Code: 550, Msg: "mailbox unavailable"},
wantCode: 550,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
engine, _ := newAuditTestEngine(t)
deliveryErr := errors.New("delivery failed")
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail(tt.name+"-failure@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
return deliveryErr, map[string]error{"example.net": tt.domainErr}
})
assertSMTPCode(t, err, tt.wantCode)
assertAuditStatuses(t, engine, consts.EmailStatusFail)
assertAuditError(t, engine, deliveryErr.Error(), true)
})
}
}
func TestPermanentDeliveryFailureSurvivesFinalAuditLock(t *testing.T) {
engine, dsn := newAuditTestEngine(t)
var blocker *sql.Tx
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("permanent-failure@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
blocker = beginAuditReadTransaction(t, dsn)
failure := &textproto.Error{Code: 550, Msg: "mailbox unavailable"}
return errors.New("delivery failed"), map[string]error{"example.net": failure}
})
assertSMTPCode(t, err, 550)
if err = blocker.Rollback(); err != nil {
t.Fatal(err)
}
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
assertAuditError(t, engine, deliveryPendingError, true)
}
func TestSameMessageIDCreatesIndependentSubmissions(t *testing.T) {
engine, _ := newAuditTestEngine(t)
var deliveries atomic.Int32
sender := func(*context.Context, *parsemail.Email) (error, map[string]error) {
deliveries.Add(1)
return nil, nil
}
session := newAuditTestSession()
for i := 0; i < 2; i++ {
email := newAuditTestEmail("reused-message-id@example.com")
email.Text = []byte(fmt.Sprintf("body %d", i))
if err := session.deliverOutgoingEmail(email, sender); err != nil {
t.Fatalf("submission %d: %v", i+1, err)
}
}
if got := deliveries.Load(); got != 2 {
t.Fatalf("downstream deliveries = %d, want 2", got)
}
assertAuditCounts(t, engine, 2, 2)
assertMessageIDCount(t, engine, "reused-message-id@example.com", 2)
}
func TestSendAfterHookCompletesBeforeFinalPersistAndSMTPResult(t *testing.T) {
engine, _ := newAuditTestEngine(t)
hook := &blockingSendAfterHook{
authenticationCaptureHook: &authenticationCaptureHook{},
started: make(chan struct{}),
release: make(chan struct{}),
done: make(chan struct{}),
}
hooks.HookList = map[string]framework.EmailHook{"blocking": hook}
result := make(chan error, 1)
go func() {
result <- newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("sync-hook@example.com"), successfulAuditSender)
}()
t.Cleanup(func() {
select {
case <-hook.release:
default:
close(hook.release)
}
})
select {
case <-hook.started:
case <-time.After(time.Second):
t.Fatal("SendAfter hook was not started")
}
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
select {
case err := <-result:
t.Fatalf("delivery returned before SendAfter hook completed: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(hook.release)
select {
case <-hook.done:
case <-time.After(time.Second):
t.Fatal("SendAfter hook did not finish")
}
select {
case err := <-result:
if err != nil {
t.Fatalf("deliverOutgoingEmail() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("delivery did not return after SendAfter hook completed")
}
assertAuditStatuses(t, engine, consts.EmailStatusSent)
}
func TestOutgoingPendingAuditMailboxVisibility(t *testing.T) {
engine, _ := newAuditTestEngine(t)
session := newAuditTestSession()
@ -417,19 +317,6 @@ func TestOutgoingPendingAuditMailboxVisibility(t *testing.T) {
}
}
type blockingSendAfterHook struct {
*authenticationCaptureHook
started chan struct{}
release chan struct{}
done chan struct{}
}
func (h *blockingSendAfterHook) SendAfter(*context.Context, *parsemail.Email, map[string]error) {
defer close(h.done)
close(h.started)
<-h.release
}
func newAuditTestEngine(t *testing.T) (*xorm.Engine, string) {
t.Helper()
dsn := filepath.Join(t.TempDir(), "audit.db") + "?_pragma=busy_timeout(20)"
@ -439,7 +326,7 @@ func newAuditTestEngine(t *testing.T) (*xorm.Engine, string) {
}
engine.SetMaxOpenConns(1)
engine.SetMaxIdleConns(1)
if err = engine.Sync2(&models.User{}, &models.Email{}, &models.UserEmail{}); err != nil {
if err = engine.Sync2(&models.User{}, &models.Email{}, &models.UserEmail{}, &models.OutboundDelivery{}, &models.ProtocolCredential{}, &models.ClientCredential{}); err != nil {
engine.Close()
t.Fatal(err)
}

View File

@ -27,7 +27,6 @@ import (
"git.pcux.cn/xuans/Smail/utils/array"
"git.pcux.cn/xuans/Smail/utils/async"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/send"
"git.pcux.cn/xuans/Smail/utils/validation"
"github.com/emersion/go-smtp"
"github.com/mileusna/spf"
@ -170,23 +169,21 @@ func (s *Session) Data(r io.Reader) (resultErr error) {
}
details.Stage = "保存并投递客户端邮件"
sender := outgoingSender(send.Send)
trigger := "SMTP"
if s.isNotification() {
details.Stage = "校验系统通知配额并投递"
if err := s.prepareNotification(email, len(emailData)); err != nil {
return err
}
sender = func(ctx *context.Context, email *parsemail.Email) (error, map[string]error) {
return send.SendTriggered(ctx, email, "系统通知 SMTP 发信")
}
trigger = "系统通知 SMTP 发信"
}
email.EnvelopeTo = make([]*parsemail.User, len(s.To))
for index, address := range s.To {
email.EnvelopeTo[index] = &parsemail.User{EmailAddress: address}
}
resultErr = s.deliverOutgoingEmail(email, sender)
resultErr = s.deliverOutgoingEmail(email, trigger)
if resultErr == nil {
result = "accepted"
result = "success"
}
return resultErr
@ -337,7 +334,7 @@ func authorizedSubmissionEmail(email *parsemail.Email, envelopeFrom string, ctx
return envelopeFrom == "" || validation.IsAuthorizedSender(envelopeFrom, ctx.UserAccount, allowLocalAlias, domains.Accepted)
}
func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) {
func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool, enqueue ...auditEnqueuer) ([]*models.User, *models.Email, error) {
if email == nil {
return nil, nil, oerrors.New("email is missing")
}
@ -396,6 +393,10 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
return users, nil, fmt.Errorf("encode stored email fields: %w", err)
}
sendDate := time.Now()
if parsed, parseErr := time.Parse(time.RFC3339, email.Date); parseErr == nil {
sendDate = parsed
}
modelEmail := models.Email{
Type: cast.ToInt8(emailType),
Subject: email.Subject,
@ -415,7 +416,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
SPFCheck: boolInt8(SPFStatus),
DKIMCheck: boolInt8(dkimStatus),
SendUserID: sendUserID,
SendDate: time.Now(),
SendDate: sendDate,
Status: cast.ToInt8(email.Status),
CreateTime: time.Now(),
CronSendTime: time.Now(),
@ -434,7 +435,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
}
log.WithContext(ctx).Debugf("开始入库!")
saved, err := persistInitialAudit(ctx, modelEmail, userIDs)
saved, err := persistInitialAudit(ctx, modelEmail, userIDs, enqueue...)
if err != nil {
return users, nil, err
}

View File

@ -8,6 +8,7 @@ type OutboundDelivery struct {
Attempts int `xorm:"notnull"`
NextAttempt int64 `xorm:"notnull"`
LastError string `xorm:"text"`
Submission string `xorm:"text" json:"-"`
}
func (OutboundDelivery) TableName() string { return "outbound_delivery" }

View File

@ -1,6 +1,10 @@
package auth
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"sync/atomic"
@ -8,6 +12,7 @@ import (
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/services/notification"
"xorm.io/xorm"
)
// ProtocolSession 保存不可变身份与凭据快照,拒绝后的连接不能通过重新启用恢复权限。
@ -18,6 +23,85 @@ type ProtocolSession struct {
invalid atomic.Bool
}
type QueuedAuthorization struct {
UserID int `json:"user_id"`
Kind string `json:"kind"`
ID int `json:"id"`
Version int `json:"version"`
Credential string `json:"credential"`
Identity string `json:"identity"`
}
func (s *ProtocolSession) QueueAuthorization() (QueuedAuthorization, error) {
var result QueuedAuthorization
if err := s.Check(); err != nil {
return result, err
}
if s.IsNotification() {
result.Kind, result.ID, result.Version, result.Credential = s.notification.QueueBinding()
} else {
result.Kind, result.ID, result.Version, result.Credential = s.grant.QueueBinding()
}
if result.Kind == "" || result.ID <= 0 || result.Version <= 0 {
return QueuedAuthorization{}, notification.ErrDenied
}
result.Identity = queuedUserIdentity(s.user)
result.UserID = s.user.ID
return result, nil
}
func queuedDigest(value string) string {
hash := sha256.Sum256([]byte(value))
return hex.EncodeToString(hash[:])
}
func queuedUserIdentity(user models.User) string {
value, _ := json.Marshal([]any{
user.ID, user.Account, user.Password, user.SessionVersion,
user.SecurityVersion, user.EffectiveRole(), user.MustChangePassword,
})
return queuedDigest(string(value))
}
func (a QueuedAuthorization) Check(engine *xorm.Engine, user models.User) (bool, error) {
if engine == nil {
return false, notification.ErrUnavailable
}
if user.ID <= 0 || a.UserID != user.ID || user.Disabled != 0 || user.MustChangePassword != 0 || a.ID <= 0 || a.Version <= 0 ||
subtle.ConstantTimeCompare([]byte(a.Identity), []byte(queuedUserIdentity(user))) != 1 {
return false, nil
}
var hash string
switch a.Kind {
case "smtp":
var item models.ProtocolCredential
found, err := engine.ID(a.ID).Where("user_id = ? AND protocol = ? AND enabled = ? AND code_version = ? AND revoked_at IS NULL", user.ID, "smtp", true, a.Version).Get(&item)
if err != nil || !found {
return false, err
}
hash = item.CodeHash
case "client":
var item models.ClientCredential
found, err := engine.ID(a.ID).Where("user_id = ? AND code_version = ? AND revoked_at IS NULL", user.ID, a.Version).Get(&item)
if err != nil || !found {
return false, err
}
hash = item.CodeHash
case "notification":
if a.ID != user.ID {
return false, nil
}
item, err := notification.Load(engine, user.ID)
if err != nil || !notification.Configured(item, user) || item.CodeVersion != a.Version {
return false, err
}
hash = item.CodeHash
default:
return false, nil
}
return hash != "" && subtle.ConstantTimeCompare([]byte(a.Credential), []byte(queuedDigest(hash))) == 1, nil
}
func AuthenticateProtocolSession(protocol, remoteAddress, account, plain string) (*models.User, *ProtocolSession, error) {
if notification.IsUsername(account) {
return authenticateNotification(protocol, remoteAddress, account, plain)

View File

@ -2,6 +2,7 @@ package auth
import (
"path/filepath"
"strings"
"sync"
"testing"
"time"
@ -11,9 +12,74 @@ import (
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/services/notification"
"xorm.io/xorm"
)
func TestQueuedNotificationAuthorizationTracksCredentialVersion(t *testing.T) {
oldDB := db.Instance
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "notification.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close(); db.Instance = oldDB })
db.Instance = engine
if err := engine.Sync2(&models.User{}, &models.NotificationGrant{}); err != nil {
t.Fatal(err)
}
user := models.User{Account: "sender", Password: "test-hash", Role: "user", SessionVersion: 1, SecurityVersion: 1}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
code := "sn_" + strings.Repeat("A", 43)
item := models.NotificationGrant{
UserID: user.ID, Enabled: 1, CodeVersion: 2, CodeHash: queuedDigest(code),
SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion,
}
if _, err := engine.Insert(&item); err != nil {
t.Fatal(err)
}
grant, err := notification.Authenticate(engine, user, code)
if err != nil {
t.Fatal(err)
}
session := &ProtocolSession{user: user, notification: grant}
snapshot, err := session.QueueAuthorization()
if err != nil {
t.Fatal(err)
}
if snapshot.Kind != "notification" || snapshot.ID != user.ID || snapshot.Version != item.CodeVersion ||
snapshot.Credential == code || snapshot.Credential == item.CodeHash {
t.Fatal("notification snapshot is not bound to the original credential")
}
if valid, err := snapshot.Check(engine, user); err != nil || !valid {
t.Fatalf("notification queue authorization = %v, %v", valid, err)
}
for _, change := range []string{"disable", "version", "hash", "session", "security"} {
t.Run(change, func(t *testing.T) {
updated := item
switch change {
case "disable":
updated.Enabled = 0
case "version":
updated.CodeVersion++
case "hash":
updated.CodeHash = queuedDigest("replacement")
case "session":
updated.SessionVersion++
case "security":
updated.SecurityVersion++
}
if _, err := engine.ID(user.ID).Cols("enabled", "code_version", "code_hash", "session_version", "security_version").Update(&updated); err != nil {
t.Fatal(err)
}
if valid, err := snapshot.Check(engine, user); err != nil || valid {
t.Fatalf("stale notification queue authorization = %v, %v", valid, err)
}
})
}
}
func TestProtocolSessionRechecksUserAndCredential(t *testing.T) {
for _, kind := range []string{"protocol", "client"} {
for _, change := range []string{"revoke", "disabled", "password", "session_version", "security_version", "role", "deleted", "database_failure"} {
@ -62,6 +128,16 @@ func TestProtocolSessionRechecksUserAndCredential(t *testing.T) {
if err != nil {
t.Fatal(err)
}
queued, err := session.QueueAuthorization()
if err != nil {
t.Fatal(err)
}
if valid, err := queued.Check(engine, user); err != nil || !valid {
t.Fatalf("new queued authorization = %v, %v", valid, err)
}
if queued.Credential == code || queued.Identity == user.Password || queued.Kind == "" {
t.Fatal("queued authorization contains reusable credentials or has no kind")
}
// 命令复核不占用密码哈希容量,也不累加登录失败计数。
release := passwordtest.Hold(t)
if err := session.Check(); err != nil {
@ -93,6 +169,17 @@ func TestProtocolSessionRechecksUserAndCredential(t *testing.T) {
if err != nil {
t.Fatal(err)
}
current := user
if change != "database_failure" {
current = models.User{}
if _, err := engine.ID(user.ID).Get(&current); err != nil {
t.Fatal(err)
}
}
valid, checkErr := queued.Check(engine, current)
if valid || (change == "database_failure") != (checkErr != nil) {
t.Fatalf("stale queued authorization = %v, %v", valid, checkErr)
}
var wait sync.WaitGroup
for range 4 {
wait.Add(1)

View File

@ -1,6 +1,9 @@
package credentials
import (
"crypto/sha256"
"encoding/hex"
"git.pcux.cn/xuans/Smail/models"
"xorm.io/xorm"
)
@ -17,6 +20,18 @@ type ProtocolGrant struct {
hash string
}
func (g *ProtocolGrant) QueueBinding() (string, int, int, string) {
if g == nil || g.protocol != "smtp" {
return "", 0, 0, ""
}
kind := "smtp"
if g.client {
kind = "client"
}
hash := sha256.Sum256([]byte(g.hash))
return kind, g.id, g.version, hex.EncodeToString(hash[:])
}
func (g *ProtocolGrant) Valid(engine *xorm.Engine) bool {
if g == nil || engine == nil || engine != g.engine || g.id <= 0 {
return false

View File

@ -216,6 +216,13 @@ type Grant struct {
securityVersion int
}
func (g *Grant) QueueBinding() (string, int, int, string) {
if g == nil {
return "", 0, 0, ""
}
return "notification", g.userID, g.version, digest(g.hash)
}
func Authenticate(engine *xorm.Engine, user models.User, plain string) (*Grant, error) {
if len(plain) != 46 || !strings.HasPrefix(plain, "sn_") {
return nil, ErrDenied

View File

@ -2,18 +2,24 @@ 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"
)
@ -121,6 +127,17 @@ func (r *Runtime) processWritable(job models.OutboundDelivery) error {
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
@ -144,6 +161,17 @@ func (r *Runtime) processWritable(job models.OutboundDelivery) error {
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。
@ -151,6 +179,22 @@ func (r *Runtime) processWritable(job models.OutboundDelivery) error {
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 {

View File

@ -2,8 +2,10 @@ package outbox
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/mail"
"sort"
"strings"
"time"
@ -12,6 +14,7 @@ import (
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/logstore"
"git.pcux.cn/xuans/Smail/utils/send"
"git.pcux.cn/xuans/Smail/utils/smtp"
@ -34,6 +37,32 @@ func Enqueue(session *xorm.Session, email models.Email) error {
if err != nil {
return err
}
return enqueueRecipients(session, email, message, "")
}
type Submission struct {
EnvelopeFrom string `json:"envelope_from"`
Date string `json:"date"`
Trigger string `json:"trigger"`
Authorization auth.QueuedAuthorization `json:"authorization"`
}
func EnqueueSMTP(session *xorm.Session, email models.Email, recipients []string, submission Submission) error {
if submission.Authorization.Kind == "" || submission.Authorization.ID <= 0 || submission.Authorization.UserID != email.SendUserID || submission.Trigger == "" {
return errors.New("SMTP submission authorization is missing")
}
encoded, err := json.Marshal(submission)
if err != nil {
return err
}
message := &parsemail.Email{To: make([]*parsemail.User, len(recipients))}
for index, address := range recipients {
message.To[index] = &parsemail.User{EmailAddress: address}
}
return enqueueRecipients(session, email, message, string(encoded))
}
func enqueueRecipients(session *xorm.Session, email models.Email, message *parsemail.Email, submission string) error {
seen := make(map[string]bool)
for _, user := range append(append(message.To, message.Cc...), message.Bcc...) {
if user == nil || user.EmailAddress == "" {
@ -41,6 +70,10 @@ func Enqueue(session *xorm.Session, email models.Email) error {
}
// 本地部分保留大小写,仅合并完全相同的 SMTP 信封地址。
address := user.EmailAddress
parsed, err := mail.ParseAddress(address)
if err != nil || parsed.Address != address || len(address) > 320 {
return errors.New("invalid queue recipient")
}
if at := strings.LastIndexByte(address, '@'); at >= 0 {
address = address[:at+1] + strings.ToLower(address[at+1:])
}
@ -48,7 +81,7 @@ func Enqueue(session *xorm.Session, email models.Email) error {
continue
}
seen[address] = true
job := models.OutboundDelivery{EmailID: email.Id, Recipient: address, State: queued}
job := models.OutboundDelivery{EmailID: email.Id, Recipient: address, State: queued, Submission: submission}
if _, err := session.Insert(&job); err != nil {
return err
}

View File

@ -2,6 +2,7 @@ package outbox
import (
stdcontext "context"
"encoding/json"
"errors"
"io"
"net/textproto"
@ -10,10 +11,13 @@ import (
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/consts"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/smtp"
"xorm.io/xorm"
@ -57,6 +61,131 @@ func queueFixture(t *testing.T) (*xorm.Engine, models.Email, []models.OutboundDe
return engine, email, jobs
}
func smtpQueueFixture(t *testing.T) (*xorm.Engine, []models.OutboundDelivery, *credentials.Service) {
t.Helper()
engine, email, jobs := queueFixture(t)
oldDB, oldConfig := db.Instance, config.Instance
db.Instance = engine
config.Instance = &config.Config{Domain: "example.com", Domains: []string{"example.com"}}
t.Cleanup(func() { db.Instance, config.Instance = oldDB, oldConfig })
if err := engine.Sync2(&models.ProtocolCredential{}, &models.ClientCredential{}); err != nil {
t.Fatal(err)
}
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
if err := service.SetProtocolEnabled(email.SendUserID, "smtp", true); err != nil {
t.Fatal(err)
}
code, err := service.GenerateProtocolCode(email.SendUserID, "smtp")
if err != nil {
t.Fatal(err)
}
_, session, err := auth.AuthenticateProtocolSession("smtp", "192.0.2.95:1234", "sender@example.com", code)
if err != nil {
t.Fatal(err)
}
authorization, err := session.QueueAuthorization()
if err != nil {
t.Fatal(err)
}
submission, err := json.Marshal(Submission{
EnvelopeFrom: "sender@example.com", Date: "2026-09-27T12:30:00+08:00",
Trigger: "SMTP", Authorization: authorization,
})
if err != nil {
t.Fatal(err)
}
email.Sender = `{"EmailAddress":"sender@example.com"}`
if _, err := engine.ID(email.Id).Cols("sender").Update(&email); err != nil {
t.Fatal(err)
}
for index := range jobs {
jobs[index].Submission = string(submission)
if _, err := engine.ID(jobs[index].ID).Cols("submission").Update(&jobs[index]); err != nil {
t.Fatal(err)
}
}
return engine, jobs, service
}
func TestSMTPQueueRestoresEnvelopeWithoutRewritingHeaders(t *testing.T) {
engine, jobs, _ := smtpQueueFixture(t)
calls := 0
r := &Runtime{engine: engine, deliver: func(ctx *context.Context, message *parsemail.Email, recipient string) error {
calls++
if ctx.UserAccount != "sender" || recipient != jobs[0].Recipient || message.EnvelopeFrom != "sender@example.com" {
t.Fatal("worker did not restore SMTP identity or envelope")
}
if len(message.EnvelopeTo) != 1 || message.EnvelopeTo[0].EmailAddress != recipient || len(message.To) != 2 {
t.Fatal("envelope recipients leaked into message headers")
}
if message.MsgID != "stable@example.com" || message.Date != "2026-09-27T12:30:00+08:00" || message.DeliveryTrigger != "SMTP" {
t.Fatal("worker lost immutable submission metadata")
}
return nil
}}
if err := r.process(jobs[0]); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("deliveries = %d, want 1", calls)
}
}
func TestSMTPQueueRejectsStaleAuthorizationBeforeDelivery(t *testing.T) {
for _, change := range []string{"revoke", "disable", "rotate", "malformed", "sender", "database_failure"} {
t.Run(change, func(t *testing.T) {
engine, jobs, service := smtpQueueFixture(t)
var err error
switch change {
case "revoke":
err = service.RevokeProtocolCode(1, "smtp")
case "disable":
err = service.SetProtocolEnabled(1, "smtp", false)
case "rotate":
_, err = service.GenerateProtocolCode(1, "smtp")
case "malformed":
jobs[0].Submission = "{"
case "sender":
var submission Submission
if err := json.Unmarshal([]byte(jobs[0].Submission), &submission); err != nil {
t.Fatal(err)
}
submission.EnvelopeFrom = "other@example.com"
var encoded []byte
encoded, err = json.Marshal(submission)
jobs[0].Submission = string(encoded)
case "database_failure":
_, err = engine.Exec("ALTER TABLE protocol_credential RENAME TO unavailable_credential")
}
if err != nil {
t.Fatal(err)
}
r := &Runtime{engine: engine, deliver: func(*context.Context, *parsemail.Email, string) error {
t.Fatal("worker delivered without current authorization")
return nil
}}
err = r.process(jobs[0])
if (change == "database_failure") != (err != nil) {
t.Fatalf("process error = %v", err)
}
var stored models.OutboundDelivery
if found, err := engine.ID(jobs[0].ID).Get(&stored); err != nil || !found {
t.Fatalf("read queued job: found=%v err=%v", found, err)
}
if change == "database_failure" {
if stored.State != queued || stored.Attempts != 0 {
t.Fatal("database read failure consumed a delivery attempt")
}
} else if stored.State != failed {
t.Fatalf("revoked submission state = %s", stored.State)
}
})
}
}
func assertStatus(t *testing.T, engine *xorm.Engine, emailID int, status int8) {
t.Helper()
var email models.Email

View File

@ -95,7 +95,15 @@ func SendTriggered(ctx *context.Context, e *parsemail.Email, trigger string) (er
// SendRecipientTriggered 保留原邮件头,只向当前队列收件人发送 SMTP 信封。
func SendRecipientTriggered(ctx *context.Context, e *parsemail.Email, recipient string) error {
if e.DeliveryTrigger != "" {
err, failures := sendRecipients(ctx, e, []*parsemail.User{{EmailAddress: recipient}}, e.DeliveryTrigger)
return recipientDeliveryError(err, failures)
}
err, failures := sendRecipients(ctx, e, []*parsemail.User{{EmailAddress: recipient}}, "网页写信")
return recipientDeliveryError(err, failures)
}
func recipientDeliveryError(err error, failures map[string]error) error {
if err != nil {
for _, cause := range failures {
if cause != nil {
@ -122,7 +130,11 @@ func sendRecipients(ctx *context.Context, e *parsemail.Email, recipients []*pars
to := deliveryRecipients(e, recipients)
return doSend(ctx, fromDomain, b, to, e.From.EmailAddress, trigger)
from := e.From.EmailAddress
if e.EnvelopeTo != nil {
from = e.EnvelopeFrom
}
return doSend(ctx, fromDomain, b, to, from, trigger)
}