This commit is contained in:
parent
24670a3c2d
commit
bf1e69f480
@ -26,4 +26,7 @@ const (
|
||||
|
||||
// EmailStatusDeliveryPending marks an outbound audit whose delivery result is not final.
|
||||
EmailStatusDeliveryPending int8 = 6
|
||||
|
||||
// EmailStatusDeliveryUncertain 禁止自动重投,需人工核对接收结果。
|
||||
EmailStatusDeliveryUncertain int8 = 7
|
||||
)
|
||||
|
||||
@ -10,17 +10,14 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/dto/parsemail"
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/hooks"
|
||||
"git.pcux.cn/xuans/Smail/hooks/framework"
|
||||
"git.pcux.cn/xuans/Smail/i18n"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/outbox"
|
||||
|
||||
"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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cast"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -274,61 +271,12 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
RecordUserContacts(ctx.UserID, contactList)
|
||||
|
||||
e.MessageId = cast.ToInt64(modelEmail.Id)
|
||||
e.MsgID = modelEmail.MsgID
|
||||
|
||||
async.New(ctx).Process(func(p any) {
|
||||
errMsg := ""
|
||||
err, sendErr := send.SendTriggered(ctx, e, "网页写信")
|
||||
|
||||
log.WithContext(ctx).Debugf("插件执行--SendAfter")
|
||||
|
||||
as2 := async.New(ctx)
|
||||
for _, hook := range hooks.Snapshot() {
|
||||
if hook == nil {
|
||||
continue
|
||||
}
|
||||
as2.WaitProcess(func(hk any) {
|
||||
hk.(framework.EmailHook).SendAfter(ctx, e, sendErr)
|
||||
}, hook)
|
||||
}
|
||||
as2.Wait()
|
||||
log.WithContext(ctx).Debugf("插件执行--SendAfter")
|
||||
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
_ = db.Transaction(db.Instance, func(session *xorm.Session) error {
|
||||
if _, updateErr := session.Exec(db.WithContext(ctx, "update email set status = ?, error = ? where id = ?"), consts.EmailStatusFail, errMsg, modelEmail.Id); updateErr != nil {
|
||||
log.WithContext(ctx).Errorf("update email status failed: %+v", updateErr)
|
||||
return updateErr
|
||||
}
|
||||
if _, updateErr := session.Exec(db.WithContext(ctx, "update user_email set status = ? where email_id = ? and user_id = ?"), consts.EmailStatusFail, modelEmail.Id, ctx.UserID); updateErr != nil {
|
||||
log.WithContext(ctx).Errorf("update user_email status failed: %+v", updateErr)
|
||||
return updateErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
_ = db.Transaction(db.Instance, func(session *xorm.Session) error {
|
||||
if _, updateErr := session.Exec(db.WithContext(ctx, "update email set status = ?, error = '' where id = ?"), consts.EmailStatusSent, modelEmail.Id); updateErr != nil {
|
||||
log.WithContext(ctx).Errorf("update email status failed: %+v", updateErr)
|
||||
return updateErr
|
||||
}
|
||||
if _, updateErr := session.Exec(db.WithContext(ctx, "update user_email set status = ? where email_id = ? and user_id = ?"), consts.EmailStatusSent, modelEmail.Id, ctx.UserID); updateErr != nil {
|
||||
log.WithContext(ctx).Errorf("update user_email status failed: %+v", updateErr)
|
||||
return updateErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
}, nil)
|
||||
|
||||
response.NewSuccessResponse(i18n.GetText(ctx.Lang, "succ")).FPrint(w)
|
||||
}
|
||||
|
||||
func persistOutgoingEmail(engine *xorm.Engine, userID int, email *models.Email) error {
|
||||
return db.Transaction(engine, func(session *xorm.Session) error {
|
||||
var saved models.Email
|
||||
err := db.Transaction(engine, func(session *xorm.Session) error {
|
||||
candidate := *email
|
||||
candidate.Id = 0
|
||||
candidate.Status = consts.EmailStatusDeliveryPending
|
||||
@ -347,9 +295,16 @@ func persistOutgoingEmail(engine *xorm.Engine, userID int, email *models.Email)
|
||||
if _, err := session.Insert(&relation); err != nil {
|
||||
return err
|
||||
}
|
||||
*email = candidate
|
||||
if err := outbox.Enqueue(session, candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
saved = candidate
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
*email = saved
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func decodeAttachment(data string) (string, []byte, error) {
|
||||
|
||||
@ -20,7 +20,7 @@ func TestSendTransactionalPersistenceConsistency(t *testing.T) {
|
||||
t.Fatalf("create test engine: %v", err)
|
||||
}
|
||||
engine.SetMaxOpenConns(1)
|
||||
if err := engine.Sync2(&models.Email{}, &models.UserEmail{}); err != nil {
|
||||
if err := engine.Sync2(&models.Email{}, &models.UserEmail{}, &models.OutboundDelivery{}); err != nil {
|
||||
t.Fatalf("sync test database: %v", err)
|
||||
}
|
||||
defer engine.Close()
|
||||
@ -43,6 +43,7 @@ func TestSendTransactionalPersistenceConsistency(t *testing.T) {
|
||||
Status: consts.EmailStatusDeliveryPending,
|
||||
CreateTime: now,
|
||||
MsgID: "msg-123@example.com",
|
||||
To: `[{"EmailAddress":"recipient@example.com"}]`,
|
||||
}
|
||||
|
||||
err = persistOutgoingEmail(engine, ctx.UserID, &modelEmail)
|
||||
@ -120,18 +121,18 @@ func TestOutboxExposesDeliveryStatus(t *testing.T) {
|
||||
}
|
||||
defer engine.Close()
|
||||
engine.SetMaxOpenConns(1)
|
||||
if err := engine.Sync2(&models.Email{}, &models.UserEmail{}, &models.UserTrustedSender{}); err != nil {
|
||||
if err := engine.Sync2(&models.Email{}, &models.UserEmail{}, &models.UserTrustedSender{}, &models.OutboundDelivery{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := db.Instance
|
||||
db.Instance = engine
|
||||
defer func() { db.Instance = previous }()
|
||||
ctx := &context.Context{UserID: 1}
|
||||
email := models.Email{Subject: "pending", Type: consts.EmailTypeSend, Status: consts.EmailStatusSent}
|
||||
email := models.Email{Subject: "pending", Type: consts.EmailTypeSend, Status: consts.EmailStatusSent, To: `[{"EmailAddress":"recipient@example.com"}]`}
|
||||
if err := persistOutgoingEmail(engine, ctx.UserID, &email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, status := range []int8{consts.EmailStatusDeliveryPending, consts.EmailStatusSent, consts.EmailStatusFail} {
|
||||
for _, status := range []int8{consts.EmailStatusDeliveryPending, consts.EmailStatusSent, consts.EmailStatusFail, consts.EmailStatusDeliveryUncertain} {
|
||||
if _, err := engine.ID(email.Id).Cols("status").Update(&models.Email{Status: status}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -150,3 +151,28 @@ func TestOutboxExposesDeliveryStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistOutgoingEmailRollsBackQueueFailure(t *testing.T) {
|
||||
engine, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.Close()
|
||||
engine.SetMaxOpenConns(1)
|
||||
if err := engine.Sync2(&models.Email{}, &models.UserEmail{}, &models.OutboundDelivery{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.Exec("CREATE TRIGGER reject_queue BEFORE INSERT ON outbound_delivery BEGIN SELECT RAISE(ABORT, 'injected queue failure'); END"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := models.Email{To: `[{"EmailAddress":"recipient@example.com"}]`}
|
||||
if err := persistOutgoingEmail(engine, 1, &email); err == nil {
|
||||
t.Fatal("queue failure ignored")
|
||||
}
|
||||
for _, model := range []any{&models.Email{}, &models.UserEmail{}, &models.OutboundDelivery{}} {
|
||||
count, err := engine.Count(model)
|
||||
if err != nil || count != 0 || email.Id != 0 {
|
||||
t.Fatalf("rollback failed for %T: count=%d id=%d error=%v", model, count, email.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
backend/db/migration/outbound_delivery.go
Normal file
19
backend/db/migration/outbound_delivery.go
Normal file
@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
func outboundDeliveryStatements() map[Dialect][]string {
|
||||
return map[Dialect][]string{
|
||||
DialectSQLite: {
|
||||
`CREATE TABLE IF NOT EXISTS "outbound_delivery" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "email_id" INTEGER NOT NULL, "recipient" TEXT NOT NULL, "state" TEXT NOT NULL, "attempts" INTEGER NOT NULL DEFAULT 0, "next_attempt" BIGINT NOT NULL DEFAULT 0, "last_error" TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_outbound_delivery_email" ON "outbound_delivery"("email_id")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_outbound_delivery_due" ON "outbound_delivery"("state", "next_attempt")`,
|
||||
},
|
||||
DialectMySQL: {
|
||||
"CREATE TABLE IF NOT EXISTS `outbound_delivery` (`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `email_id` BIGINT NOT NULL, `recipient` VARCHAR(320) NOT NULL, `state` VARCHAR(24) NOT NULL, `attempts` INT NOT NULL DEFAULT 0, `next_attempt` BIGINT NOT NULL DEFAULT 0, `last_error` TEXT NOT NULL, KEY `idx_outbound_delivery_email` (`email_id`), KEY `idx_outbound_delivery_due` (`state`, `next_attempt`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
},
|
||||
DialectPostgres: {
|
||||
`CREATE TABLE IF NOT EXISTS "outbound_delivery" ("id" BIGSERIAL PRIMARY KEY, "email_id" BIGINT NOT NULL, "recipient" VARCHAR(320) NOT NULL, "state" VARCHAR(24) NOT NULL, "attempts" INTEGER NOT NULL DEFAULT 0, "next_attempt" BIGINT NOT NULL DEFAULT 0, "last_error" TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_outbound_delivery_email" ON "outbound_delivery"("email_id")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_outbound_delivery_due" ON "outbound_delivery"("state", "next_attempt")`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migration
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@ -32,6 +32,7 @@ func DefaultMigrations() []Migration {
|
||||
DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema),
|
||||
DefineCustom(21, "expand_trusted_sender_blacklist", "expand_trusted_sender_blacklist_v1", applyTrustedSenderBlacklistSchema),
|
||||
DefineForDialects(22, "create_client_credentials", clientCredentialStatements()),
|
||||
DefineForDialects(23, "create_outbound_delivery", outboundDeliveryStatements()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -296,6 +296,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
||||
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
|
||||
21: "ac1a0b3be90128c0e0512d2cec858f8cd7e70abc5ff4734d7113b9674f08dbb0",
|
||||
22: "1047cb22f5a32c8a09ba4ccedff8713a5225c14fdb98357db86a6e0723c30d8b",
|
||||
23: "5d80f2c2c583fc9a033fc320c0852c73bd6701ce6c8f12e8904deedfe0ba44b2",
|
||||
}
|
||||
migrations := DefaultMigrations()
|
||||
if len(migrations) != len(expected) {
|
||||
|
||||
13
backend/models/outbound_delivery.go
Normal file
13
backend/models/outbound_delivery.go
Normal file
@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
type OutboundDelivery struct {
|
||||
ID int64 `xorm:"id pk autoincr"`
|
||||
EmailID int `xorm:"email_id notnull index"`
|
||||
Recipient string `xorm:"varchar(320) notnull"`
|
||||
State string `xorm:"varchar(24) notnull"`
|
||||
Attempts int `xorm:"notnull"`
|
||||
NextAttempt int64 `xorm:"notnull"`
|
||||
LastError string `xorm:"text"`
|
||||
}
|
||||
|
||||
func (OutboundDelivery) TableName() string { return "outbound_delivery" }
|
||||
@ -25,6 +25,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/services/dbmigration"
|
||||
"git.pcux.cn/xuans/Smail/services/externalemail"
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
"git.pcux.cn/xuans/Smail/services/outbox"
|
||||
pluginservice "git.pcux.cn/xuans/Smail/services/plugin"
|
||||
"git.pcux.cn/xuans/Smail/services/recovery"
|
||||
"git.pcux.cn/xuans/Smail/services/restart"
|
||||
@ -47,6 +48,7 @@ var verificationRuntime *verification.Runtime
|
||||
var credentialService *credentials.Service
|
||||
var webAuthnRuntimeService *webauthnservice.Service
|
||||
var logRuntime *logstore.Runtime
|
||||
var outboxRuntime *outbox.Runtime
|
||||
|
||||
func startConfigManager() error {
|
||||
root := filepath.Join(config.ROOT_PATH, "config")
|
||||
@ -215,6 +217,11 @@ func Init(serverVersion string) error {
|
||||
}()
|
||||
}
|
||||
hooks.Init(serverVersion)
|
||||
outboxRuntime, err = outbox.Start(db.Instance)
|
||||
if err != nil {
|
||||
stopServices()
|
||||
return fmt.Errorf("start persistent outbox: %w", err)
|
||||
}
|
||||
httpRun, httpsRun, err := http_server.PrepareWebServers()
|
||||
if err != nil {
|
||||
stopServices()
|
||||
@ -294,6 +301,14 @@ func stopServices() error {
|
||||
http_server.HttpStop()
|
||||
pop3_server.Stop()
|
||||
imap_server.Stop()
|
||||
// 停止接收新请求后等待正在投递的任务写回,超时则保留数据库,避免提前关闭连接池。
|
||||
drainCtx, drainCancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
err := outboxRuntime.Stop(drainCtx)
|
||||
drainCancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("outbox did not drain; database retained: %w", err)
|
||||
}
|
||||
outboxRuntime = nil
|
||||
hooks.Stop()
|
||||
// 先关闭业务入口,再撤下安全服务及会话清理器,避免新请求读取停止中的依赖。
|
||||
stopVerificationRuntime()
|
||||
|
||||
@ -104,3 +104,40 @@ func TestMaintenanceGuardDrainsRequestsAndKeepsRestartRoute(t *testing.T) {
|
||||
t.Fatal("cleanup wrote to frozen source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceDrainsBackgroundDeliveryBeforeFreeze(t *testing.T) {
|
||||
executor := &PortableExecutor{}
|
||||
service := NewService(&DatabaseExecutor{portable: executor})
|
||||
previous := Current()
|
||||
SetCurrent(service)
|
||||
t.Cleanup(func() { SetCurrent(previous) })
|
||||
entered, finish, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = WhileWritable(func() error {
|
||||
close(entered)
|
||||
<-finish
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-entered
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
release, err := service.drainRequests(ctx)
|
||||
release()
|
||||
close(finish)
|
||||
<-done
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("migration did not wait for delivery: %v", err)
|
||||
}
|
||||
release, err = service.drainRequests(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executor.paused.Store(true)
|
||||
release()
|
||||
called := false
|
||||
if err := WhileWritable(func() error { called = true; return nil }); err != nil || called {
|
||||
t.Fatal("delivery started after source freeze")
|
||||
}
|
||||
}
|
||||
|
||||
172
backend/services/outbox/runtime.go
Normal file
172
backend/services/outbox/runtime.go
Normal file
@ -0,0 +1,172 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
stdcontext "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/dbmigration"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
"git.pcux.cn/xuans/Smail/utils/send"
|
||||
"git.pcux.cn/xuans/Smail/utils/smtp"
|
||||
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
|
||||
}
|
||||
|
||||
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:
|
||||
}
|
||||
var jobs []models.OutboundDelivery
|
||||
err := r.engine.Where("state = ? AND next_attempt <= ?", queued, time.Now().Unix()).Asc("id").Limit(20).Find(&jobs)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("读取持久投递队列失败")
|
||||
}
|
||||
for _, job := range jobs {
|
||||
select {
|
||||
case <-r.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := r.process(job); err != nil {
|
||||
log.WithError(err).WithField("delivery_id", job.ID).Error("处理持久投递队列失败")
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-r.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 读取失败时尚未开始投递,保留排队状态供数据库恢复后继续处理。
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
return r.deliver(ctx, message, job.Recipient)
|
||||
}()
|
||||
// 只重试结果落库,绝不因数据库故障再次调用 SMTP。
|
||||
var terminal bool
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
terminal, err = finish(r.engine, job, deliveryErr)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("投递结果写入失败,保留在途记录并禁止自动重投: %w", err)
|
||||
}
|
||||
if terminal && message != nil {
|
||||
r.notify(ctx, message, 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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
179
backend/services/outbox/store.go
Normal file
179
backend/services/outbox/store.go
Normal file
@ -0,0 +1,179 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/logstore"
|
||||
"git.pcux.cn/xuans/Smail/utils/send"
|
||||
"git.pcux.cn/xuans/Smail/utils/smtp"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
const (
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
accepted = "accepted"
|
||||
failed = "failed"
|
||||
uncertain = "uncertain"
|
||||
maxAttempts = 5
|
||||
uncertainMessage = "投递结果不确定,已暂停自动重试,请先核对收件结果"
|
||||
)
|
||||
|
||||
// Enqueue 必须与邮件及用户关联在同一事务内保存,不保存验证码或恢复令牌。
|
||||
func Enqueue(session *xorm.Session, email models.Email) error {
|
||||
message, err := parsemail.NewEmailFromModel(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, user := range append(append(message.To, message.Cc...), message.Bcc...) {
|
||||
if user == nil || user.EmailAddress == "" {
|
||||
return errors.New("队列收件人不能为空")
|
||||
}
|
||||
// 本地部分保留大小写,仅合并完全相同的 SMTP 信封地址。
|
||||
address := user.EmailAddress
|
||||
if at := strings.LastIndexByte(address, '@'); at >= 0 {
|
||||
address = address[:at+1] + strings.ToLower(address[at+1:])
|
||||
}
|
||||
if seen[address] {
|
||||
continue
|
||||
}
|
||||
seen[address] = true
|
||||
job := models.OutboundDelivery{EmailID: email.Id, Recipient: address, State: queued}
|
||||
if _, err := session.Insert(&job); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return errors.New("队列必须包含收件人")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarize(jobs []models.OutboundDelivery) (int8, string, bool) {
|
||||
counts := make(map[string]int)
|
||||
reasons := make(map[string]bool)
|
||||
for _, job := range jobs {
|
||||
counts[job.State]++
|
||||
if job.LastError != "" {
|
||||
reasons[job.LastError] = true
|
||||
}
|
||||
}
|
||||
if counts[queued]+counts[running] > 0 {
|
||||
return consts.EmailStatusDeliveryPending, "", false
|
||||
}
|
||||
details := make([]string, 0, len(reasons))
|
||||
for reason := range reasons {
|
||||
details = append(details, reason)
|
||||
}
|
||||
sort.Strings(details)
|
||||
suffix := ""
|
||||
if len(details) > 0 {
|
||||
suffix = ";原因:" + strings.Join(details, ";")
|
||||
}
|
||||
if counts[uncertain] > 0 {
|
||||
return consts.EmailStatusDeliveryUncertain, fmt.Sprintf("%s;已接收 %d,结果不确定 %d,失败 %d", uncertainMessage, counts[accepted], counts[uncertain], counts[failed]) + suffix, true
|
||||
}
|
||||
if len(jobs) > 0 && counts[accepted] == len(jobs) {
|
||||
return consts.EmailStatusSent, "", true
|
||||
}
|
||||
return consts.EmailStatusFail, fmt.Sprintf("投递未全部完成;已接收 %d,失败 %d。已接收的收件人不会自动重投", counts[accepted], counts[failed]) + suffix, true
|
||||
}
|
||||
|
||||
func syncStatus(session *xorm.Session, emailID int) (bool, error) {
|
||||
var jobs []models.OutboundDelivery
|
||||
if err := session.Where("email_id = ?", emailID).Find(&jobs); err != nil {
|
||||
return false, err
|
||||
}
|
||||
status, message, terminal := summarize(jobs)
|
||||
if _, err := session.ID(emailID).Cols("status", "error").Update(&models.Email{Status: status, Error: sql.NullString{String: message, Valid: true}}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 不改动用户已放入回收站的关联状态。
|
||||
_, err := session.Where("email_id = ? AND status <> ?", emailID, consts.EmailStatusDel).Cols("status").Update(&models.UserEmail{Status: status})
|
||||
return terminal, err
|
||||
}
|
||||
|
||||
// recoverInterrupted 只在独占运行实例启动时执行,在途记录绝不重新排队。
|
||||
func recoverInterrupted(engine *xorm.Engine) error {
|
||||
return db.Transaction(engine, func(session *xorm.Session) error {
|
||||
// 旧版本未记录 SMTP 尝试边界,只标记待核对,不能当作未发送任务导入。
|
||||
var legacy []models.Email
|
||||
if err := session.Where("type = ? AND status = ? AND id NOT IN (SELECT email_id FROM outbound_delivery)", consts.EmailTypeSend, consts.EmailStatusDeliveryPending).Find(&legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, email := range legacy {
|
||||
if _, err := session.ID(email.Id).Cols("status", "error").Update(&models.Email{Status: consts.EmailStatusDeliveryUncertain, Error: sql.NullString{String: uncertainMessage, Valid: true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := session.Where("email_id = ? AND status = ?", email.Id, consts.EmailStatusDeliveryPending).Cols("status").Update(&models.UserEmail{Status: consts.EmailStatusDeliveryUncertain}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var interrupted []models.OutboundDelivery
|
||||
if err := session.Where("state = ?", running).Find(&interrupted); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := session.Where("state = ?", running).Cols("state", "last_error").Update(&models.OutboundDelivery{State: uncertain, LastError: uncertainMessage}); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[int]bool)
|
||||
for _, job := range interrupted {
|
||||
if !seen[job.EmailID] {
|
||||
if _, err := syncStatus(session, job.EmailID); err != nil {
|
||||
return err
|
||||
}
|
||||
seen[job.EmailID] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func claim(engine *xorm.Engine, job *models.OutboundDelivery) (bool, error) {
|
||||
update := *job
|
||||
update.State = running
|
||||
update.Attempts++
|
||||
count, err := engine.ID(job.ID).Where("state = ? AND attempts = ? AND next_attempt <= ?", queued, job.Attempts, time.Now().Unix()).Cols("state", "attempts").Update(&update)
|
||||
if err == nil && count == 1 {
|
||||
*job = update
|
||||
}
|
||||
return count == 1, err
|
||||
}
|
||||
|
||||
func finish(engine *xorm.Engine, job models.OutboundDelivery, deliveryErr error) (bool, error) {
|
||||
job.State, job.LastError, job.NextAttempt = accepted, "", 0
|
||||
if deliveryErr != nil {
|
||||
// 不保存远端响应原文,避免凭据或恶意超长内容进入数据库和页面。
|
||||
job.State, job.LastError = failed, logstore.MailFailureReason(deliveryErr)
|
||||
if smtp.IsDeliveryUncertain(deliveryErr) {
|
||||
job.State = uncertain
|
||||
job.LastError = uncertainMessage
|
||||
} else if send.Retryable(deliveryErr) && job.Attempts < maxAttempts {
|
||||
job.State = queued
|
||||
job.NextAttempt = time.Now().Add(time.Minute * time.Duration(1<<(job.Attempts-1))).Unix()
|
||||
}
|
||||
}
|
||||
terminal := false
|
||||
err := db.Transaction(engine, func(session *xorm.Session) error {
|
||||
count, err := session.ID(job.ID).Where("state = ? AND attempts = ?", running, job.Attempts).Cols("state", "last_error", "next_attempt").Update(&job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errors.New("投递任务状态已发生变化")
|
||||
}
|
||||
terminal, err = syncStatus(session, job.EmailID)
|
||||
return err
|
||||
})
|
||||
return terminal, err
|
||||
}
|
||||
340
backend/services/outbox/store_test.go
Normal file
340
backend/services/outbox/store_test.go
Normal file
@ -0,0 +1,340 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
stdcontext "context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/utils/context"
|
||||
"git.pcux.cn/xuans/Smail/utils/smtp"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func queueFixture(t *testing.T) (*xorm.Engine, models.Email, []models.OutboundDelivery) {
|
||||
t.Helper()
|
||||
engine, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { engine.Close() })
|
||||
if err := engine.Sync2(&models.Email{}, &models.UserEmail{}, &models.User{}, &models.OutboundDelivery{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := models.User{Account: "sender", Name: "sender", Password: "test", Role: "user"}
|
||||
if _, err := engine.Insert(&user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := models.Email{Type: consts.EmailTypeSend, Status: consts.EmailStatusDeliveryPending, SendUserID: user.ID, FromAddress: "sender@example.com", To: `[{"EmailAddress":"one@example.com"},{"EmailAddress":"two@example.com"}]`, Cc: `[{"EmailAddress":"one@EXAMPLE.com"}]`, MsgID: "stable@example.com"}
|
||||
err = db.Transaction(engine, func(session *xorm.Session) error {
|
||||
if _, err := session.Insert(&email); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := session.Insert(&models.UserEmail{UserID: user.ID, EmailID: email.Id, Status: consts.EmailStatusDeliveryPending}); err != nil {
|
||||
return err
|
||||
}
|
||||
return Enqueue(session, email)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var jobs []models.OutboundDelivery
|
||||
if err := engine.Asc("id").Find(&jobs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(jobs) != 2 {
|
||||
t.Fatalf("duplicate recipient: %v", jobs)
|
||||
}
|
||||
return engine, email, jobs
|
||||
}
|
||||
|
||||
func assertStatus(t *testing.T, engine *xorm.Engine, emailID int, status int8) {
|
||||
t.Helper()
|
||||
var email models.Email
|
||||
var relation models.UserEmail
|
||||
if ok, err := engine.ID(emailID).Get(&email); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := engine.Where("email_id = ?", emailID).Get(&relation); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email.Status != status || relation.Status != status {
|
||||
t.Fatalf("email=%d relation=%d want=%d", email.Status, relation.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimAndPartialResults(t *testing.T) {
|
||||
engine, email, jobs := queueFixture(t)
|
||||
first := jobs[0]
|
||||
if ok, err := claim(engine, &first); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := claim(engine, &jobs[0]); err != nil || ok {
|
||||
t.Fatalf("duplicate claim: %v %v", ok, err)
|
||||
}
|
||||
if _, err := finish(engine, first, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStatus(t, engine, email.Id, consts.EmailStatusDeliveryPending)
|
||||
second := jobs[1]
|
||||
if ok, err := claim(engine, &second); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := finish(engine, second, &textproto.Error{Code: 451, Msg: "later"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var queuedJob models.OutboundDelivery
|
||||
if _, err := engine.ID(second.ID).Get(&queuedJob); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queuedJob.State != queued || queuedJob.NextAttempt <= time.Now().Unix() {
|
||||
t.Fatalf("retry state=%+v", queuedJob)
|
||||
}
|
||||
if ok, err := claim(engine, &queuedJob); err != nil || ok {
|
||||
t.Fatal("retry was claimed before backoff elapsed")
|
||||
}
|
||||
if _, err := engine.ID(queuedJob.ID).Cols("next_attempt").Update(&models.OutboundDelivery{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := claim(engine, &queuedJob); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := finish(engine, queuedJob, &textproto.Error{Code: 550, Msg: "rejected"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStatus(t, engine, email.Id, consts.EmailStatusFail)
|
||||
if ok, err := claim(engine, &first); err != nil || ok {
|
||||
t.Fatal("accepted job was claimed again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartDoesNotResendInterruptedOrAccepted(t *testing.T) {
|
||||
engine, email, jobs := queueFixture(t)
|
||||
for i := range jobs {
|
||||
if ok, err := claim(engine, &jobs[i]); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := finish(engine, jobs[0], nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := recoverInterrupted(engine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStatus(t, engine, email.Id, consts.EmailStatusDeliveryUncertain)
|
||||
var stored []models.OutboundDelivery
|
||||
if err := engine.Asc("id").Find(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored[0].State != accepted || stored[1].State != uncertain {
|
||||
t.Fatalf("states=%+v", stored)
|
||||
}
|
||||
if err := recoverInterrupted(engine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncertainDATAIsNeverRetried(t *testing.T) {
|
||||
engine, _, jobs := queueFixture(t)
|
||||
job := jobs[0]
|
||||
if ok, err := claim(engine, &job); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := finish(engine, job, &smtp.DeliveryUncertainError{Cause: io.EOF}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := claim(engine, &job); err != nil || ok {
|
||||
t.Fatal("uncertain job was claimed again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultFailureRollsBackAndNeverRedelivers(t *testing.T) {
|
||||
engine, email, jobs := queueFixture(t)
|
||||
if _, err := engine.Exec("CREATE TRIGGER reject_result BEFORE UPDATE ON user_email BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
r := &Runtime{engine: engine, deliver: func(_ *context.Context, e *parsemail.Email, recipient string) error {
|
||||
calls.Add(1)
|
||||
if e.MsgID != "stable@example.com" || len(e.To) != 2 || recipient != "one@example.com" {
|
||||
t.Fatal("envelope changed message headers")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
if err := r.process(jobs[0]); err == nil {
|
||||
t.Fatal("result failure was ignored")
|
||||
}
|
||||
if err := r.process(jobs[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("SMTP called %d times", calls.Load())
|
||||
}
|
||||
assertStatus(t, engine, email.Id, consts.EmailStatusDeliveryPending)
|
||||
var job models.OutboundDelivery
|
||||
if _, err := engine.ID(jobs[0].ID).Get(&job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.State != running {
|
||||
t.Fatalf("state=%s", job.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedJobsSurviveRestartAndDrain(t *testing.T) {
|
||||
engine, email, _ := queueFixture(t)
|
||||
if err := recoverInterrupted(engine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
r := &Runtime{engine: engine, stop: make(chan struct{}), done: make(chan struct{}), deliver: func(_ *context.Context, _ *parsemail.Email, _ string) error { calls.Add(1); return nil }}
|
||||
go r.loop()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for calls.Load() < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(stdcontext.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := r.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("calls=%d", calls.Load())
|
||||
}
|
||||
assertStatus(t, engine, email.Id, consts.EmailStatusSent)
|
||||
}
|
||||
|
||||
func TestRetryLimitAndSafeFailureDetails(t *testing.T) {
|
||||
engine, _, jobs := queueFixture(t)
|
||||
job := jobs[0]
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if _, err := engine.ID(job.ID).Cols("next_attempt").Update(&models.OutboundDelivery{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := claim(engine, &job); err != nil || !ok {
|
||||
t.Fatalf("claim %d: %v", attempt, err)
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
if _, err := finish(engine, job, &textproto.Error{Code: 451, Msg: "sensitive-remote-response"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var refreshed models.OutboundDelivery
|
||||
if found, err := engine.ID(job.ID).Get(&refreshed); err != nil || !found {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job = refreshed
|
||||
if strings.Contains(job.LastError, "sensitive") || !strings.Contains(job.LastError, "451") {
|
||||
t.Fatalf("unsafe or missing failure details: %s", job.LastError)
|
||||
}
|
||||
if attempt < maxAttempts {
|
||||
minimum := before + int64(60*(1<<(attempt-1)))
|
||||
if job.State != queued || job.NextAttempt < minimum || job.NextAttempt > minimum+2 {
|
||||
t.Fatalf("unexpected backoff: %+v", job)
|
||||
}
|
||||
} else if job.State != failed || job.NextAttempt != 0 {
|
||||
t.Fatalf("retry limit ignored: %+v", job)
|
||||
}
|
||||
}
|
||||
status, message, terminal := summarize([]models.OutboundDelivery{job})
|
||||
if status != consts.EmailStatusFail || !terminal || !strings.Contains(message, "451") {
|
||||
t.Fatalf("failure summary: %d %s %v", status, message, terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPendingIsUncertainWithoutEnqueue(t *testing.T) {
|
||||
engine, _, _ := queueFixture(t)
|
||||
email := models.Email{Type: consts.EmailTypeSend, Status: consts.EmailStatusDeliveryPending}
|
||||
if _, err := engine.Insert(&email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.Insert(&models.UserEmail{UserID: 1, EmailID: email.Id, Status: consts.EmailStatusDeliveryPending}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := recoverInterrupted(engine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStatus(t, engine, email.Id, consts.EmailStatusDeliveryUncertain)
|
||||
count, err := engine.Where("email_id = ?", email.Id).Count(&models.OutboundDelivery{})
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("legacy message enqueued: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFailureDoesNotClaimOrDeliver(t *testing.T) {
|
||||
engine, _, jobs := queueFixture(t)
|
||||
if _, err := engine.Exec("ALTER TABLE email RENAME TO unavailable_email"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &Runtime{engine: engine, deliver: func(_ *context.Context, _ *parsemail.Email, _ string) error {
|
||||
t.Fatal("SMTP called despite read failure")
|
||||
return nil
|
||||
}}
|
||||
if err := r.process(jobs[0]); err == nil {
|
||||
t.Fatal("read failure ignored")
|
||||
}
|
||||
var job models.OutboundDelivery
|
||||
if _, err := engine.ID(jobs[0].ID).Get(&job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.State != queued || job.Attempts != 0 {
|
||||
t.Fatalf("read failure consumed attempt: %+v", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimeoutDoesNotStartAnotherDelivery(t *testing.T) {
|
||||
engine, _, _ := queueFixture(t)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
r := &Runtime{engine: engine, stop: make(chan struct{}), done: make(chan struct{}), deliver: func(_ *context.Context, _ *parsemail.Email, _ string) error {
|
||||
if calls.Add(1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-release
|
||||
return nil
|
||||
}}
|
||||
go r.loop()
|
||||
<-entered
|
||||
ctx, cancel := stdcontext.WithTimeout(stdcontext.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
err := r.Stop(ctx)
|
||||
close(release)
|
||||
if !errors.Is(err, stdcontext.DeadlineExceeded) {
|
||||
t.Fatalf("stop did not time out: %v", err)
|
||||
}
|
||||
if err := r.Stop(stdcontext.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("delivery started after stop: %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDoesNotRestoreTrashedRelation(t *testing.T) {
|
||||
engine, email, jobs := queueFixture(t)
|
||||
if _, err := engine.Where("email_id = ?", email.Id).Cols("status").Update(&models.UserEmail{Status: consts.EmailStatusDel}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if ok, err := claim(engine, &job); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := finish(engine, job, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var relation models.UserEmail
|
||||
if _, err := engine.Where("email_id = ?", email.Id).Get(&relation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if relation.Status != consts.EmailStatusDel {
|
||||
t.Fatal("delivery restored trashed message")
|
||||
}
|
||||
}
|
||||
93
backend/utils/send/routing.go
Normal file
93
backend/utils/send/routing.go
Normal file
@ -0,0 +1,93 @@
|
||||
package send
|
||||
|
||||
import (
|
||||
stdcontext "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/utils/smtp"
|
||||
)
|
||||
|
||||
var errNullMX = errors.New("收件域名声明不接收邮件")
|
||||
|
||||
type mxLookup func(stdcontext.Context, string) ([]*net.MX, error)
|
||||
|
||||
func resolveMX(domain string, lookup mxLookup) ([]string, error) {
|
||||
ctx, cancel := stdcontext.WithTimeout(stdcontext.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
records, err := lookup(ctx, domain)
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound || dnsErr.IsTemporary || dnsErr.IsTimeout {
|
||||
return nil, fmt.Errorf("查询收件域名 MX 失败: %w", err)
|
||||
}
|
||||
// 无 MX 时使用域名自身的地址记录,不猜测 smtp 子域名。
|
||||
return []string{domain}, nil
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return []string{domain}, nil
|
||||
}
|
||||
records = append([]*net.MX(nil), records...)
|
||||
for _, record := range records {
|
||||
if record == nil || record.Host == "" {
|
||||
return nil, errors.New("收件域名 MX 记录无效")
|
||||
}
|
||||
if record.Host == "." {
|
||||
return nil, errNullMX
|
||||
}
|
||||
}
|
||||
sort.SliceStable(records, func(i, j int) bool { return records[i].Pref < records[j].Pref })
|
||||
hosts := make([]string, 0, len(records))
|
||||
seen := make(map[string]bool)
|
||||
for _, record := range records {
|
||||
host := strings.ToLower(strings.TrimSuffix(record.Host, "."))
|
||||
if !seen[host] {
|
||||
hosts = append(hosts, host)
|
||||
seen[host] = true
|
||||
}
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func stopFallback(err error) bool {
|
||||
return smtp.IsDeliveryUncertain(err) || isCertificateVerificationError(err) || isPermanentSMTPResponse(err) || errors.Is(err, smtp.ErrUnsafeRemoteAddress)
|
||||
}
|
||||
|
||||
// deliverMX 只在确认本次未接收时切换安全通道,DATA 不确定结果不进入下一条路由。
|
||||
func deliverMX(hosts []string, attempt func(host, port string) error) error {
|
||||
var lastErr error
|
||||
for _, host := range hosts {
|
||||
for _, port := range []string{"25", "587", "465"} {
|
||||
lastErr = attempt(host, port)
|
||||
if lastErr == nil || stopFallback(lastErr) {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("收件域名没有可用邮件服务器")
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Retryable 仅允许有明确临时失败语义的结果进入持久队列重试。
|
||||
func Retryable(err error) bool {
|
||||
if err == nil || stopFallback(err) {
|
||||
return false
|
||||
}
|
||||
var response *textproto.Error
|
||||
if errors.As(err, &response) {
|
||||
return response.Code >= 400 && response.Code <= 499
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return dnsErr.IsTimeout || dnsErr.IsTemporary
|
||||
}
|
||||
var networkErr net.Error
|
||||
return errors.As(err, &networkErr)
|
||||
}
|
||||
95
backend/utils/send/routing_test.go
Normal file
95
backend/utils/send/routing_test.go
Normal file
@ -0,0 +1,95 @@
|
||||
package send
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/utils/smtp"
|
||||
)
|
||||
|
||||
func TestResolveMX(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
records []*net.MX
|
||||
err error
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "priority and duplicate", records: []*net.MX{{Host: "backup.example.", Pref: 20}, {Host: "primary.example.", Pref: 10}, {Host: "PRIMARY.example.", Pref: 30}}, want: []string{"primary.example", "backup.example"}},
|
||||
{name: "implicit MX", want: []string{"example.com"}},
|
||||
{name: "no MX record", err: &net.DNSError{IsNotFound: true}, want: []string{"example.com"}},
|
||||
{name: "temporary DNS", err: &net.DNSError{IsTemporary: true}, wantErr: true},
|
||||
{name: "timeout DNS", err: &net.DNSError{IsTimeout: true}, wantErr: true},
|
||||
{name: "null MX", records: []*net.MX{{Host: "."}}, wantErr: true},
|
||||
{name: "invalid MX", records: []*net.MX{nil}, wantErr: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hosts, err := resolveMX("example.com", func(ctx context.Context, domain string) ([]*net.MX, error) {
|
||||
if _, ok := ctx.Deadline(); !ok || domain != "example.com" {
|
||||
t.Fatal("unbounded or wrong lookup")
|
||||
}
|
||||
return tt.records, tt.err
|
||||
})
|
||||
if (err != nil) != tt.wantErr || !reflect.DeepEqual(hosts, tt.want) {
|
||||
t.Fatalf("hosts=%v error=%v", hosts, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverMXFallbackAndStop(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
err error
|
||||
calls int
|
||||
}{
|
||||
{"network fallback", &net.OpError{Op: "dial", Err: io.EOF}, 4},
|
||||
{"temporary refusal fallback", &textproto.Error{Code: 451, Msg: "later"}, 4},
|
||||
{"permanent refusal", &textproto.Error{Code: 550, Msg: "rejected"}, 1},
|
||||
{"certificate failure", &tls.CertificateVerificationError{Err: io.EOF}, 1},
|
||||
{"uncertain DATA", &smtp.DeliveryUncertainError{Cause: io.EOF}, 1},
|
||||
{"private MX", smtp.ErrUnsafeRemoteAddress, 1},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
err := deliverMX([]string{"primary.example", "backup.example"}, func(host, port string) error {
|
||||
calls = append(calls, host+":"+port)
|
||||
if host == "backup.example" {
|
||||
return nil
|
||||
}
|
||||
return tt.err
|
||||
})
|
||||
if len(calls) != tt.calls {
|
||||
t.Fatalf("calls=%v", calls)
|
||||
}
|
||||
if tt.calls == 4 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.calls == 1 && !errors.Is(err, tt.err) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryable(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{&textproto.Error{Code: 451}, true}, {&net.DNSError{IsTemporary: true}, true},
|
||||
{&net.DNSError{IsNotFound: true}, false}, {&textproto.Error{Code: 550}, false},
|
||||
{&smtp.DeliveryUncertainError{Cause: &net.DNSError{IsTimeout: true}}, false},
|
||||
{&tls.CertificateVerificationError{Err: io.EOF}, false}, {nil, false},
|
||||
} {
|
||||
if got := Retryable(tt.err); got != tt.want {
|
||||
t.Fatalf("Retryable(%v)=%v", tt.err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
"git.pcux.cn/xuans/Smail/utils/array"
|
||||
"git.pcux.cn/xuans/Smail/utils/async"
|
||||
"git.pcux.cn/xuans/Smail/utils/consts"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
"git.pcux.cn/xuans/Smail/utils/smtp"
|
||||
@ -89,6 +88,23 @@ func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
|
||||
|
||||
// SendTriggered 显式标记业务发起方,不从邮件主题或内容推断来源。
|
||||
func SendTriggered(ctx *context.Context, e *parsemail.Email, trigger string) (error, map[string]error) {
|
||||
return sendRecipients(ctx, e, nil, trigger)
|
||||
}
|
||||
|
||||
// SendRecipientTriggered 保留原邮件头,只向当前队列收件人发送 SMTP 信封。
|
||||
func SendRecipientTriggered(ctx *context.Context, e *parsemail.Email, recipient string) error {
|
||||
err, failures := sendRecipients(ctx, e, []*parsemail.User{{EmailAddress: recipient}}, "网页写信")
|
||||
if err != nil {
|
||||
for _, cause := range failures {
|
||||
if cause != nil {
|
||||
return cause
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func sendRecipients(ctx *context.Context, e *parsemail.Email, recipients []*parsemail.User, trigger string) (error, map[string]error) {
|
||||
_, fromDomain := e.From.GetDomainAccount()
|
||||
|
||||
b, err := e.BuildBytes(ctx, true)
|
||||
@ -104,6 +120,9 @@ func SendTriggered(ctx *context.Context, e *parsemail.Email, trigger string) (er
|
||||
|
||||
var to []*parsemail.User
|
||||
to = append(append(append(to, e.To...), e.Cc...), e.Bcc...)
|
||||
if recipients != nil {
|
||||
to = recipients
|
||||
}
|
||||
|
||||
return doSend(ctx, fromDomain, b, to, e.From.EmailAddress, trigger)
|
||||
|
||||
@ -176,8 +195,7 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai
|
||||
invalidErrors := make(map[string]error)
|
||||
|
||||
// 按域名整理
|
||||
toByDomain := map[mxDomain][]*parsemail.User{}
|
||||
mxLookupErrors := map[mxDomain]error{}
|
||||
toByDomain := map[string][]*parsemail.User{}
|
||||
for _, s := range to {
|
||||
address, domain, addressErr := parseRecipientAddress(s)
|
||||
if addressErr != nil {
|
||||
@ -189,41 +207,7 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai
|
||||
log.WithContext(ctx).WithError(addressErr).Error("invalid recipient address")
|
||||
continue
|
||||
}
|
||||
args := []string{address, domain}
|
||||
if len(args) == 2 {
|
||||
if args[1] == consts.TEST_DOMAIN {
|
||||
// 测试使用
|
||||
address := mxDomain{
|
||||
domain: "localhost",
|
||||
mxHost: "127.0.0.1",
|
||||
localTest: true,
|
||||
}
|
||||
toByDomain[address] = append(toByDomain[address], s)
|
||||
} else {
|
||||
//查询dns mx记录
|
||||
mxInfo, lookupErr := net.LookupMX(args[1])
|
||||
address := mxDomain{
|
||||
domain: "smtp." + args[1],
|
||||
mxHost: "smtp." + args[1],
|
||||
}
|
||||
if lookupErr != nil {
|
||||
log.WithContext(ctx).Errorf("%s 域名mx记录查询失败,检查邮箱是否存在!", s.EmailAddress)
|
||||
}
|
||||
if len(mxInfo) > 0 {
|
||||
address = mxDomain{
|
||||
domain: args[1],
|
||||
mxHost: mxInfo[0].Host,
|
||||
}
|
||||
}
|
||||
if lookupErr != nil {
|
||||
mxLookupErrors[address] = lookupErr
|
||||
}
|
||||
toByDomain[address] = append(toByDomain[address], s)
|
||||
}
|
||||
} else {
|
||||
log.WithContext(ctx).Errorf("邮箱地址解析错误! %s", s)
|
||||
continue
|
||||
}
|
||||
toByDomain[domain] = append(toByDomain[domain], s)
|
||||
}
|
||||
|
||||
errEmailAddress := append([]string(nil), invalidAddresses...)
|
||||
@ -234,14 +218,14 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai
|
||||
errMap.Store(address, err)
|
||||
}
|
||||
|
||||
as := async.New(ctx)
|
||||
var workers sync.WaitGroup
|
||||
for domain, tos := range toByDomain {
|
||||
domain := domain
|
||||
tos := tos
|
||||
mxLookupErr := mxLookupErrors[domain]
|
||||
as.WaitProcess(func(p any) {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
recordFailure := func(err error) {
|
||||
err = deliveryFailureCause(mxLookupErr, err)
|
||||
log.WithContext(ctx).Errorf("%v 邮件投递失败%+v", tos, err)
|
||||
|
||||
errEmailAddressMu.Lock()
|
||||
@ -250,11 +234,16 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai
|
||||
}
|
||||
errEmailAddressMu.Unlock()
|
||||
|
||||
errMap.Store(domain.domain, err)
|
||||
errMap.Store(domain, err)
|
||||
}
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
recordFailure(&smtp.DeliveryUncertainError{Cause: fmt.Errorf("投递任务异常: %v", value)})
|
||||
}
|
||||
}()
|
||||
|
||||
if allowUnsafeSMTP(domain) {
|
||||
err := smtp.SendMailUnsafe("localhost", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data)
|
||||
if domain == consts.TEST_DOMAIN {
|
||||
err := smtp.SendMailUnsafe("localhost", "127.0.0.1:25", nil, from, fromDomain, buildAddress(tos), data)
|
||||
if err != nil {
|
||||
recordFailure(err)
|
||||
} else {
|
||||
@ -263,71 +252,26 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai
|
||||
return
|
||||
}
|
||||
|
||||
// 优先尝试25端口,starttls方式投递
|
||||
err := smtp.SendMail("", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data)
|
||||
if err == nil {
|
||||
hosts, err := resolveMX(domain, net.DefaultResolver.LookupMX)
|
||||
if err != nil {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
err = deliverMX(hosts, func(host, port string) error {
|
||||
address := net.JoinHostPort(host, port)
|
||||
if port == "465" {
|
||||
return smtp.SendMailWithTls(host, address, nil, from, fromDomain, buildAddress(tos), data)
|
||||
}
|
||||
return smtp.SendMail(host, address, nil, from, fromDomain, buildAddress(tos), data)
|
||||
})
|
||||
if err != nil {
|
||||
recordFailure(err)
|
||||
} else {
|
||||
accepted.Add(int64(len(tos)))
|
||||
return
|
||||
}
|
||||
if isCertificateVerificationError(err) {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
if isPermanentSMTPResponse(err) {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"action": "SMTP 邮件投递",
|
||||
"result": "failure",
|
||||
"object": domain.mxHost + ":25",
|
||||
}).Infof("SMTP 25 端口 STARTTLS 投递失败: %s", err.Error())
|
||||
|
||||
// 587 使用 STARTTLS,只有 465 使用隐式 TLS。
|
||||
err = smtp.SendMail(strings.TrimSuffix(domain.mxHost, "."), domain.mxHost+":587", nil, from, fromDomain, buildAddress(tos), data)
|
||||
if err == nil {
|
||||
accepted.Add(int64(len(tos)))
|
||||
return
|
||||
}
|
||||
if isCertificateVerificationError(err) {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
if isPermanentSMTPResponse(err) {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"action": "SMTP 邮件投递",
|
||||
"result": "failure",
|
||||
"object": domain.mxHost + ":587",
|
||||
}).Infof("SMTP 587 端口投递失败: %s", err.Error())
|
||||
|
||||
// 再次尝试465投递
|
||||
err = smtp.SendMailWithTls(strings.TrimSuffix(domain.mxHost, "."), domain.mxHost+":465", nil, from, fromDomain, buildAddress(tos), data)
|
||||
if err == nil {
|
||||
accepted.Add(int64(len(tos)))
|
||||
return
|
||||
}
|
||||
if isCertificateVerificationError(err) {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
if isPermanentSMTPResponse(err) {
|
||||
recordFailure(err)
|
||||
return
|
||||
}
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"action": "SMTP 邮件投递",
|
||||
"result": "failure",
|
||||
"object": domain.mxHost + ":465",
|
||||
}).Infof("SMTP 465 端口投递失败: %s", err.Error())
|
||||
|
||||
// 所有安全投递方式均失败后记录错误,不允许回退到明文连接。
|
||||
recordFailure(err)
|
||||
}, nil)
|
||||
}()
|
||||
}
|
||||
as.Wait()
|
||||
workers.Wait()
|
||||
|
||||
orgMap := map[string]error{}
|
||||
errMap.Range(func(key, value any) bool {
|
||||
|
||||
@ -45,6 +45,22 @@ var EOFError = errors.New("EOF")
|
||||
var ErrUnsafeRemoteAddress = errors.New("smtp: remote address is not public")
|
||||
var ErrUnsafePlaintextTarget = errors.New("smtp: plaintext delivery is restricted to localhost")
|
||||
|
||||
// DeliveryUncertainError 表示 DATA 已开始,但未取得明确接收或拒绝响应,禁止自动重投。
|
||||
type DeliveryUncertainError struct {
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *DeliveryUncertainError) Error() string {
|
||||
return "投递结果不确定,请核对收件结果后再操作: " + e.Cause.Error()
|
||||
}
|
||||
|
||||
func (e *DeliveryUncertainError) Unwrap() error { return e.Cause }
|
||||
|
||||
func IsDeliveryUncertain(err error) bool {
|
||||
var uncertain *DeliveryUncertainError
|
||||
return errors.As(err, &uncertain)
|
||||
}
|
||||
|
||||
const (
|
||||
outboundSMTPDialTimeout = 2 * time.Second
|
||||
outboundSMTPTLSHandshakeTimeout = 2 * time.Second
|
||||
@ -554,10 +570,14 @@ func (c *Client) Data() (io.WriteCloser, error) {
|
||||
|
||||
func finishDelivery(c *Client, w io.WriteCloser, msg []byte) error {
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
return &DeliveryUncertainError{Cause: err}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
var rejection *textproto.Error
|
||||
if errors.As(err, &rejection) && rejection.Code >= 400 && rejection.Code <= 599 {
|
||||
return err
|
||||
}
|
||||
return &DeliveryUncertainError{Cause: err}
|
||||
}
|
||||
|
||||
// The final 250 response to DATA transfers responsibility for the message.
|
||||
|
||||
@ -219,6 +219,10 @@ func startSMTPTransactionServer(t *testing.T, dataResponse string, holdWithoutQu
|
||||
done <- fmt.Errorf("message body was not received: %q", body)
|
||||
return
|
||||
}
|
||||
if dataResponse == "" {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
if err := text.PrintfLine("%s", dataResponse); err != nil {
|
||||
done <- err
|
||||
return
|
||||
@ -244,6 +248,17 @@ func startSMTPTransactionServer(t *testing.T, dataResponse string, holdWithoutQu
|
||||
return listener.Addr().String(), done
|
||||
}
|
||||
|
||||
func TestLostFinalDATAResponseIsUncertain(t *testing.T) {
|
||||
addr, done := startSMTPTransactionServer(t, "", false)
|
||||
err := SendMailUnsafe("localhost", addr, nil, "sender@example.com", "example.com", []string{"recipient@example.net"}, []byte("Subject: test\r\n\r\nbody\r\n"))
|
||||
if !IsDeliveryUncertain(err) {
|
||||
t.Fatalf("error=%v, want uncertain delivery", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectSMTPCommand(conn *textproto.Conn, prefix string) error {
|
||||
line, err := conn.ReadLine()
|
||||
if err != nil {
|
||||
|
||||
63
docs/生产整改验收清单.md
Normal file
63
docs/生产整改验收清单.md
Normal file
@ -0,0 +1,63 @@
|
||||
# 生产整改验收清单
|
||||
|
||||
## 范围与结论
|
||||
|
||||
本清单对应 2026 年 9 月 17 日 `D:\Desktop\Smail` 生产审查整改,验收服务器为 `47.108.78.12`。应用和自动测试只在该服务器执行,SMTP 按用户要求使用远程 `127.0.0.1:25`,不将公网 25 端口连通列为本轮通过条件。
|
||||
|
||||
当前版本已经完成凭据配置、SMTP 判定、MX 路由和网页持久投递队列的代码整改及针对性远程测试,但仍不建议直接生产上线。测试范围、版本摘要和备份位置以《远程服务器测试记录》最新章节为准。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 编号 | 优先级 | 任务 | 状态 | 验收边界 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PROD-001 | P0 | 移除脚本中的已知明文凭据 | [x] | 工作区已清理,历史泄露处置未结束 |
|
||||
| PROD-002 | P0 | 轮换泄露凭据、核查访问记录 | [!] | 旧 SSH 密码仍有效,需要协调替代登录及轮换 |
|
||||
| PROD-003 | P0 | 处置 Git 历史中的凭据 | [!] | 未授权重写历史或强推;轮换应先于历史清理 |
|
||||
| PROD-004 | P0 | 持久化逐收件人投递、防不确定重投 | [x] | 相关自动测试通过,真实网页业务闭环待验收 |
|
||||
| PROD-005 | P1 | MX、备用路由、DNS 错误分类 | [✓] | 针对性测试通过,不代表各外部邮件供应商兼容性 |
|
||||
| PROD-006 | P0 | 可信 TLS 和正式域名验证 | [!] | 仍使用自签名测试证书,不关闭证书校验 |
|
||||
| PROD-007 | P0 | 完整网页登录、发信和状态核对 | [!] | 需要有效测试账号和可信证书;不得绕过登录 |
|
||||
| PROD-008 | P0 | 超级管理员恢复完整交互 | [!] | IPC 权限已复核,正式密码代理交互及旧会话撤销未验收 |
|
||||
| PROD-009 | P1 | 备份与恢复演练 | [x] | 完整备份、归档及双库校验通过;异机恢复未演练 |
|
||||
| PROD-010 | P1 | 内部 SMTP 与部署后安全回归 | [✓] | 五项真实收信、双库、产物、未认证 API 均通过 |
|
||||
| PROD-011 | P1 | 全量测试、竞态、数据库兼容性和压测 | [ ] | 本轮仅七包 132 个顶层用例;未运行真实 MySQL/PostgreSQL |
|
||||
| PROD-012 | P1 | 最新版本清库首次安装验收 | [ ] | 当前为保留数据升级,不能沿用旧版本清库结果 |
|
||||
|
||||
状态含义:`[ ]` 未开始,`[~]` 进行中,`[x]` 已完成待复审,`[✓]` 指定范围复审通过,`[!]` 暂缓或存在阻塞。
|
||||
|
||||
## 凭据与 SSH
|
||||
|
||||
SSH 脚本复用 `test/tools/remote_ssh.py`,强制核对已知主机密钥。不得为连接方便改回自动接受未知主机,也不要把真实值写入命令记录、仓库或文档。
|
||||
|
||||
| 环境变量 | 用途 |
|
||||
| --- | --- |
|
||||
| `SMAIL_SSH_HOST`、`SMAIL_SSH_PORT` | SSH 主机和端口 |
|
||||
| `SMAIL_SSH_USER` | 登录账号 |
|
||||
| `SMAIL_SSH_PASSWORD` | 临时注入的密码,优先协调迁移到密钥认证 |
|
||||
| `SMAIL_SSH_KEY` | 私钥路径,不是私钥内容 |
|
||||
| `SMAIL_SSH_KNOWN_HOSTS` | 额外的已核验主机密钥文件路径 |
|
||||
| `SMAIL_TEST_ADMIN_PASSWORD` | 测试安装时使用的管理员密码 |
|
||||
| `SMAIL_TEST_SMTP_PASSWORD` | SMTP 示例客户端凭据 |
|
||||
|
||||
轮换前必须验证新的独立登录路径,避免锁死服务器访问;还应撤销关联会话、令牌及重复使用该密码的账号凭据,并审查异常登录。历史清理需要单独授权、备份及协作安排,当前未删除文件、重写历史或强推。删除源码中的密码不会让旧密码失效。
|
||||
|
||||
## 投递语义与运维边界
|
||||
|
||||
- 网页返回提交成功表示邮件和任务已经持久化,不表示收件箱已收到。SMTP 250 仅表示对端接收了投递责任。
|
||||
- 每收件人独立记录 `queued`、`running`、`accepted`、`failed` 或 `uncertain`。成功收件人不随其他收件人的失败重投。
|
||||
- 临时失败最多五次,间隔 1、2、4、8 分钟;明确拒绝、证书问题、危险目标和结果不确定停止自动重试。
|
||||
- DATA 结果不确定、重启时仍在途、旧版本没有可靠尝试记录的邮件必须先核对实际收件结果,再决定人工处理。当前没有已验收的人工重投界面,不要直接改库重排。
|
||||
- 数据库读取失败不领取任务;结果写库失败只重试写入三次,仍失败则保留在途状态并记录错误。恢复数据库后需要核查日志及受控重启,不能把它理解成无限自动恢复。
|
||||
- 当前仅允许单实例、单工作器使用同一队列库。没有验收多实例调度、吞吐容量、队列告警和故障期间的服务等级。
|
||||
- 现有邮件客户端 SMTP 提交、系统通知和其他调用方不自动等同于网页持久队列;分别验收其投递及失败处理。
|
||||
- 备用 MX 保留既有 25 STARTTLS、587 STARTTLS、465 TLS 顺序;禁止明文降级。各运营商的端口策略及可达性需要独立验证。
|
||||
|
||||
## 下一轮验收顺序
|
||||
|
||||
1. 验证替代 SSH 登录,完成泄露凭据轮换和访问审计,再另行协调历史清理。
|
||||
2. 部署覆盖实际域名的受信任证书,使用有效测试账号恢复桌面浏览器验收;移动端遵循指定插件流程。
|
||||
3. 从正常网页提交邮件,核对持久任务、部分成功、最终状态和实际收件结果;对重启、暂时拒绝、DATA 断连及数据库故障使用受控远程故障环境,不影响其他服务。
|
||||
4. 用受保护的本地管理命令完成恢复交互,核对新密码登录、旧密码拒绝、旧会话及客户端凭据撤销。不得增加普通 HTTP 或公网/LAN TCP 恢复入口。
|
||||
5. 在隔离远程目标完成备份恢复、全量与竞态测试、目标数据库兼容性和容量验证。最新版本重新清库安装前,先备份所有项目数据库并核实范围,不清理其他项目数据。
|
||||
|
||||
上述阻塞项解决后再做最终生产评审,不以构建成功、首页 200、内部 SMTP 250 或单次提交代替整体验收。
|
||||
@ -158,3 +158,76 @@ TLS 前未公布 AUTH,主动尝试明文 AUTH 返回 523。未认证外域邮
|
||||
- Chrome DevTools 访问远程站点时被现有自签名证书拦截,停留在证书错误页;这与此前记录的受信任 TLS 阻塞一致,不能据此宣称网页渲染或真实点击验收通过。
|
||||
|
||||
因此,本轮审查确认服务、部署产物、数据库完整性及未认证接口边界正常;批量已读的真实网页登录、选择邮件、点击“标记为已读”、刷新页面和混合归属网页请求仍保持阻塞,待提供有效测试账号及受信任测试证书后继续。数据库没有执行清理,历史测试邮件和备份继续保留。
|
||||
|
||||
## 2026 年 9 月 17 日生产整改升级回归
|
||||
|
||||
本节记录后续生产整改版本,不覆盖前文历史证据。本轮应用及测试二进制仍只在 `47.108.78.12` 运行;本机仅进行源码检查、前端构建、Linux 交叉编译和 SSH 操作。本轮采用备份后保留双库升级,没有清库,不作为重新安装验收。
|
||||
|
||||
### 整改内容
|
||||
|
||||
- 测试及部署脚本统一读取环境变量中的 SSH 凭据,校验已知主机密钥,不再自动信任未知主机。网页安装及 SMTP 示例密码也改为环境变量输入。
|
||||
- 网页发信在同一事务内保存邮件、用户关联及每收件人的持久投递任务。进程重启后,尚未尝试的任务可以继续;已在途任务及旧版无队列的待投递邮件标记为“投递结果待核对”,不盲目重投。
|
||||
- 临时网络、DNS 或 SMTP 4xx 失败最多尝试五次,间隔为 1、2、4、8 分钟。明确拒绝、证书错误、私网目标和 DATA 结果不确定均不自动重试。
|
||||
- MX 按优先级排序并去重,支持备用 MX、无 MX 时使用域名自身及 Null MX 拒绝;保留既有安全端口回退。邮件头保留完整收件信息,队列任务只投递当前收件人的 SMTP 信封。
|
||||
- SMTP DATA 最终 250 后的 QUIT 异常不再误判失败;发送 DATA 后失去明确结果的任务暂停核对。结果写库失败只重试数据库写入,不重复调用 SMTP。
|
||||
- 后台投递接入在线数据库迁移排空边界;停机等待投递结束,不提前关闭仍在使用的数据库。投递状态更新不恢复已放入回收站的用户关联。
|
||||
|
||||
### 自动测试
|
||||
|
||||
七个相关 Linux 测试包在远程以 `smail` 账号执行,使用隔离内存或临时数据库,不改部署业务库。共 132 个顶层测试用例通过,日志保留在 `/var/tmp/smail-review-20260917/*.result.log`。
|
||||
|
||||
| 测试包 | 顶层用例数 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| `outbox` | 10 | 通过 |
|
||||
| `utils-send` | 11 | 通过 |
|
||||
| `utils-smtp` | 8 | 通过 |
|
||||
| `controllers-email` | 12 | 通过 |
|
||||
| `db-migration` | 26 | 通过 |
|
||||
| `res_init` | 14 | 通过 |
|
||||
| `services-dbmigration` | 51 | 通过 |
|
||||
|
||||
新增覆盖包括部分投递成功、防重复领取、退避及重试上限、DATA 不确定结果、结果写库故障、读取失败、旧待投递记录、重启恢复、停机超时、回收站状态、队列插入失败整事务回滚和迁移等待后台投递。这里的测试通过不等于真实网页登录发信闭环已通过。
|
||||
|
||||
前端生产构建和 Linux amd64 编译通过,仍有编辑器分块超过 500 kB 的构建提示。没有重新执行全部后端包、竞态检测、移动端、真实 MySQL 或 PostgreSQL 验证。
|
||||
|
||||
### 部署证据
|
||||
|
||||
2026 年 9 月 17 日 10:06 完成双程序升级,版本为 `remote-13bcad90e832`,源码指纹为 `13bcad90e832a9be78604854a1c91f72cfaf2a21770481c627e50ac361ce3b70`。
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| `/opt/smail/smail` SHA-256 | `a3b4a0b0e75213bb7997fa55f789d98bca027e879c9dbfe986f7b1ee8c8230d9` |
|
||||
| `/opt/smail/smail-admin` SHA-256 | `16e48a16d3f47ab78b42fb1aa854dfc32ec2ccd3a95694c6ccb93fc1da075e34` |
|
||||
| 数据库迁移版本 | 23 |
|
||||
| 两份 SQLite 完整性 | 均为 `ok` |
|
||||
| 服务状态 | `active/running`,`NRestarts=0`,`ExecMainStatus=0` |
|
||||
| 本地恢复 socket | `/run/smail/admin-control.sock`,`smail:smail 0600` |
|
||||
| HTTPS 首页 | 显式测试 CA、主机名校验下返回 200 |
|
||||
| 未认证用户信息、邮件列表接口 | 均返回业务码 403,无业务数据 |
|
||||
| 本次检查时段日志 | 25 条 journal 记录,未发现 error、fatal 或 panic 标记 |
|
||||
|
||||
完整停服备份为 `/var/backups/smail-before-outbox-20260917-100608`,归档 SHA-256 为 `a2ed56666e9e691a63dfc6734ebe2a1e9c7bf2fe4ceb81cb55028e66ea52d016`;包含原主程序、管理员工具、双库及完整部署归档。归档比对和数据库备份完整性检查通过,没有删除历史文件。此次部署成功,未实际演练失败回滚或异机恢复。
|
||||
|
||||
### 内部 SMTP 回归
|
||||
|
||||
运行号 `20260917-100912`,仅从远程连接 `127.0.0.1:25`。五项均使用 TLS 1.3,DATA 返回 250,并核对真实数据库归属、正文和附件字节:
|
||||
|
||||
| 用例 | 邮件 ID | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 中文纯文本 | 27 | 仅普通用户关联,正文一致 |
|
||||
| HTML、中文附件及多收件人 | 28 | 管理员和普通用户关联,附件一致 |
|
||||
| 管理员专属附件 | 29 | 仅管理员关联 |
|
||||
| 未知本域收件人 | 30 | 过滤级别 0 下转管理员收件箱 |
|
||||
| 未认证外域收件人 | 31 | 转管理员收件箱,无发信记录 |
|
||||
|
||||
结果及测试前双库备份位于 `/var/backups/smail-internal-smtp-20260917-100912`。没有修改过滤策略、系统 CA、证书验证或 DNS;客户端仅显式信任已有测试证书。内部接收链路通过,不代表网页发信、外部投递或生产 TLS 验收通过。
|
||||
|
||||
### 结论与限制
|
||||
|
||||
- [✓] 本轮指定的七包测试、部署产物核对、双库完整性、内部 SMTP 和未认证接口边界复审通过。
|
||||
- [x] 凭据硬编码清理完成;工作区已知旧 SSH 密码扫描零命中,但旧密码仍有效,Git 历史仍含旧凭据,不能视为泄露处置完成。
|
||||
- [!] 新队列尚未完成真实网页登录发信闭环;部署后的队列表为空,不能用入站 SMTP 回归代替出站队列业务验收。
|
||||
- [!] 当前队列按单实例、单工作器运行,未压测。结果落库持续失败时保留在途任务,需要告警和受控重启后转为待核对;不得人工直接改回排队状态。
|
||||
- [!] 受信任 TLS、凭据轮换、正式管理员恢复交互、新密码登录与旧会话撤销、灾备恢复及全量兼容性验证仍未完成。
|
||||
|
||||
当前结论仍为:适合继续受控远程验收,不建议直接用于正式生产。后续门槛见《生产整改验收清单》。
|
||||
|
||||
@ -81,6 +81,11 @@
|
||||
<el-tag type="warning" effect="light" size="small" v-if="scope.row.status === 6">
|
||||
待投递
|
||||
</el-tag>
|
||||
<el-tooltip :content="scope.row.error" placement="top" v-else-if="scope.row.status === 7">
|
||||
<el-tag type="warning" effect="light" size="small" class="clickable-tag" @click.stop="showError(scope.row)">
|
||||
<el-icon><Warning /></el-icon> 投递结果待核对
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag type="success" effect="light" size="small" v-else-if="scope.row.status === 1 && !scope.row.error">
|
||||
<el-icon><Check /></el-icon> 发送成功
|
||||
</el-tag>
|
||||
@ -115,7 +120,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 错误详情对话框 -->
|
||||
<el-dialog v-model="errorDialogVisible" title="邮件发送失败详情" width="500px">
|
||||
<el-dialog v-model="errorDialogVisible" title="邮件投递详情" width="500px">
|
||||
<div class="error-dialog-body">
|
||||
<p class="error-label">错误原因:</p>
|
||||
<el-alert :title="currentErrorMsg" type="error" :closable="false" show-icon />
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
from email.mime.text import MIMEText
|
||||
import smtplib
|
||||
import os
|
||||
msg = MIMEText('hello, send by Python...', 'plain', 'utf-8')
|
||||
|
||||
|
||||
from_addr = "admin@domain.com"
|
||||
password = "admin"
|
||||
password = os.environ["SMAIL_TEST_SMTP_PASSWORD"]
|
||||
to_addr = "admin@domain.com"
|
||||
smtp_server = "127.0.0.1"
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from tools.remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
sftp = ssh.open_sftp()
|
||||
print('Uploading out/smail to /opt/smail/smail.new ...')
|
||||
sftp.put('out/smail', '/opt/smail/smail.new')
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import urllib.request, urllib.parse, hmac, hashlib, base64, time, json, paramiko, subprocess
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(client)
|
||||
stdin, stdout, stderr = client.exec_command('cat /opt/smail/ssl/dkim/dkim.priv')
|
||||
priv_key = stdout.read().decode('utf-8')
|
||||
client.close()
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
_, out, _ = ssh.exec_command('cat /opt/smail/config/smtp.json; echo ===; cat /opt/smail/config/email.json')
|
||||
print(out.read().decode('utf-8', errors='ignore'))
|
||||
ssh.close()
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT * FROM client_credential;\"")
|
||||
print('client_credential:\n', stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT * FROM protocol_credential;\"")
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, from_address, send_user_id, status, error, create_time FROM email WHERE id >= 20;"')
|
||||
print("Security questions:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, group_id, status, create FROM user_email WHERE email_id >= 20;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, from_address, [to], send_user_id, status FROM email WHERE id >= 10;"')
|
||||
print("Emails >= 10:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, group_id, status FROM user_email WHERE email_id >= 10;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, error FROM email WHERE id = 24;"')
|
||||
print("Email 24 error:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM email WHERE id = 24;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT sql FROM sqlite_master WHERE name='external_email';\"")
|
||||
print('SCHEMA:\n', stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT * FROM external_email;\"")
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT e.id, e.subject, e.type, ue.status, ue.group_id, ue.is_read FROM email e LEFT JOIN user_email ue ON e.id = ue.email_id WHERE ue.user_id = 1 AND ue.status = 0 AND e.type = 0 AND (ue.group_id = 0 OR ue.group_id = 1) ORDER BY e.id DESC;"')
|
||||
print("Inbox query result:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT e.id, e.subject, e.type, e.status, e.error FROM email e WHERE e.send_user_id = 1 ORDER BY e.id DESC LIMIT 5;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command("tail -n 25 /opt/smail/log/stdout.log")
|
||||
print(stdout.read().decode("utf-8", errors="ignore"))
|
||||
stdin, stdout, stderr = ssh.exec_command("ps aux | grep smail")
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('tail -n 40 /opt/smail/log/runtime-2026-09-14.log')
|
||||
print("Grep 6567567:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('tail -n 40 /opt/smail/log/stdout.log')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=8)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(client)
|
||||
cmd = 'systemctl list-unit-files | grep -E "(mysql|mariadb|postgresql)"'
|
||||
stdin, stdout, stderr = client.exec_command(cmd)
|
||||
print("DB units:\n", stdout.read().decode('utf-8'))
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
_, out, _ = ssh.exec_command('ss -tulpn; ps aux | grep smail')
|
||||
print(out.read().decode('utf-8', errors='ignore'))
|
||||
ssh.close()
|
||||
@ -1,8 +1,9 @@
|
||||
import paramiko
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!")
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
sftp = ssh.open_sftp()
|
||||
with sftp.open("/tmp/test_api.py", "w") as f:
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "date"')
|
||||
print("Security questions:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "date -u"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('date')
|
||||
print("Server Date:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('uptime')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email_id FROM user_email WHERE email_id = 22;"')
|
||||
print("Security questions:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email_id FROM user_email WHERE email_id = 23;"')
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import paramiko
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
def run(cmd):
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''import sqlite3, base64, glob
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
121
test/tools/deploy_review.py
Normal file
121
test/tools/deploy_review.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""仅在 47 服务器备份并升级 Smail,保留双库和失败证据。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path('/opt/smail')
|
||||
STAGING = Path('/var/tmp/smail-review-20260917')
|
||||
DATABASES = ('smail.db', 'smail-state.db')
|
||||
|
||||
|
||||
def run(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def snapshot(source, target):
|
||||
with sqlite3.connect(f'file:{source}?mode=ro', uri=True) as original:
|
||||
with sqlite3.connect(target) as copied:
|
||||
original.backup(copied)
|
||||
assert copied.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
|
||||
target.chmod(0o600)
|
||||
|
||||
|
||||
def wait_ready():
|
||||
for _ in range(60):
|
||||
result = subprocess.run([
|
||||
'curl', '--silent', '--fail', '--max-time', '2', '--noproxy', '*',
|
||||
'--cacert', str(ROOT / 'ssl/custom.crt'), '--resolve',
|
||||
'mail.solit.cn:443:127.0.0.1', 'https://mail.solit.cn/api/ping',
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode == 0 and run('systemctl', 'is-active', 'smail.service') == 'active':
|
||||
return
|
||||
time.sleep(1)
|
||||
raise RuntimeError('升级后服务未就绪')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--main-sha', required=True)
|
||||
parser.add_argument('--admin-sha', required=True)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
assert os.geteuid() == 0 and ROOT.resolve() == ROOT and not ROOT.is_symlink()
|
||||
assert run('hostname') == 'ikun2'
|
||||
assert '172.31.134.42/20' in run('ip', '-4', 'address', 'show')
|
||||
assert run('systemctl', 'show', 'smail.service', '--property=User', '--value') == 'smail'
|
||||
assert run('systemctl', 'is-active', 'smail.service') == 'active'
|
||||
expected = {'smail': args.main_sha, 'smail-admin': args.admin_sha}
|
||||
for name, checksum in expected.items():
|
||||
path = STAGING / name
|
||||
with path.open('rb') as binary:
|
||||
header = binary.read(20)
|
||||
assert header[:5] == b'\x7fELF\x02' and header[18:20] == b'\x3e\x00'
|
||||
assert digest(path) == checksum
|
||||
assert not (ROOT / name).is_symlink()
|
||||
|
||||
backup = Path('/var/backups') / time.strftime('smail-before-outbox-%Y%m%d-%H%M%S')
|
||||
backup.mkdir(mode=0o700)
|
||||
installed = False
|
||||
subprocess.run(['systemctl', 'stop', 'smail.service'], check=True)
|
||||
try:
|
||||
# 停服归档包含配置、密钥和日志,仅保存在远程受保护目录。
|
||||
archive = backup / 'smail.tar.gz'
|
||||
subprocess.run(['tar', '-czf', str(archive), '-C', '/opt', 'smail'], check=True)
|
||||
subprocess.run(['tar', '-dzf', str(archive), '-C', '/opt'], check=True)
|
||||
archive.chmod(0o600)
|
||||
for name in DATABASES:
|
||||
source = ROOT / 'data' / name
|
||||
assert source.resolve() == source and not source.is_symlink()
|
||||
snapshot(source, backup / name)
|
||||
for name in expected:
|
||||
shutil.copy2(ROOT / name, backup / name)
|
||||
installed = True
|
||||
for name, checksum in expected.items():
|
||||
subprocess.run(['install', '-m', '0755', '-o', 'root', '-g', 'root',
|
||||
str(STAGING / name), str(ROOT / name)], check=True)
|
||||
assert digest(ROOT / name) == checksum
|
||||
subprocess.run(['systemctl', 'start', 'smail.service'], check=True)
|
||||
wait_ready()
|
||||
for name in DATABASES:
|
||||
with sqlite3.connect(f'file:{ROOT / "data" / name}?mode=ro', uri=True) as database:
|
||||
assert database.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
|
||||
with sqlite3.connect(f'file:{ROOT / "data/smail.db"}?mode=ro', uri=True) as database:
|
||||
version = database.execute('SELECT MAX(version) FROM schema_migration').fetchone()[0]
|
||||
assert version == 23
|
||||
states = dict(database.execute('SELECT state,COUNT(*) FROM outbound_delivery GROUP BY state'))
|
||||
result = {'backup': str(backup), 'archive_sha256': digest(archive),
|
||||
'artifacts': expected, 'migration': version, 'queue_states': states,
|
||||
'service': run('systemctl', 'is-active', 'smail.service'),
|
||||
'admin_version': run(str(ROOT / 'smail-admin'), 'version'),
|
||||
'database_integrity': 'ok', 'database_reset': False}
|
||||
(backup / 'result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
except BaseException:
|
||||
if installed:
|
||||
subprocess.run(['systemctl', 'stop', 'smail.service'], check=True)
|
||||
for name in DATABASES:
|
||||
snapshot(ROOT / 'data' / name, backup / ('failed-' + name))
|
||||
snapshot(backup / name, ROOT / 'data' / name)
|
||||
shutil.chown(ROOT / 'data' / name, user='smail', group='smail')
|
||||
for name in expected:
|
||||
shutil.copy2(backup / name, ROOT / name)
|
||||
subprocess.run(['systemctl', 'start', 'smail.service'], check=True)
|
||||
print('升级失败,备份及故障证据保留于:' + str(backup))
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko, time
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
s_in, s_out, s_err = ssh.exec_command("chmod +x /opt/smail/smail_new && pkill -9 -f smail || true")
|
||||
s_out.channel.recv_exit_status()
|
||||
time.sleep(1)
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, subject, send_user_id, send_time FROM email ;"')
|
||||
print("Emails:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, is_trash FROM user_email ;"')
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
import paramiko, time
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
cmd = """python3 -c '
|
||||
import hashlib, json
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(client)
|
||||
cmd = 'openssl rsa -in /opt/smail/ssl/dkim/dkim.priv -pubout -outform PEM'
|
||||
stdin, stdout, stderr = client.exec_command(cmd)
|
||||
lines = [l.strip() for l in stdout.read().decode('utf-8').splitlines() if '---' not in l and l.strip()]
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
_, stdout, _ = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema client_credential"')
|
||||
print(stdout.read().decode())
|
||||
_, stdout, _ = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema protocol_credential"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, from_address, send_user_id, status FROM email ORDER BY id ASC;"')
|
||||
print("Security questions:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, count(*), sum(consumed) FROM recovery_code GROUP BY user_id;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, group_id, status FROM user_email ORDER BY id DESC LIMIT 10;"')
|
||||
print("Security questions:\n", stdout.read().decode("utf-8", errors="ignore").encode("utf-8"))
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, send_user_id, status FROM email ORDER BY id DESC LIMIT 10;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, question FROM security_question;"')
|
||||
print("Security questions:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, count(*), sum(consumed) FROM recovery_code GROUP BY user_id;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema security_question"')
|
||||
print("Schema security_question:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema recovery_code"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM security_question WHERE user_id = 2;"')
|
||||
print("Testuser2 security question:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM recovery_code WHERE user_id = 2;"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, account, name, role, is_admin, disabled FROM user;"')
|
||||
print("Users in DB:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".tables"')
|
||||
|
||||
45
test/tools/remote_review.py
Normal file
45
test/tools/remote_review.py
Normal file
@ -0,0 +1,45 @@
|
||||
import argparse
|
||||
import pathlib
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import paramiko
|
||||
|
||||
from remote_ssh import connect_remote
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--command")
|
||||
parser.add_argument("--upload", nargs=2, action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
client = connect_remote(paramiko.SSHClient())
|
||||
try:
|
||||
with client.open_sftp() as sftp:
|
||||
for source, destination in args.upload:
|
||||
target = pathlib.PurePosixPath(destination)
|
||||
if (not target.is_absolute() or ".." in target.parts
|
||||
or len(target.parts) < 5 or target.parts[1:3] != ("var", "tmp")
|
||||
or not target.parts[3].startswith("smail-review-")):
|
||||
raise ValueError("上传路径必须位于项目审查临时目录")
|
||||
command = "install -d -m 0755 " + shlex.quote(str(target.parent))
|
||||
_, output, error = client.exec_command(command)
|
||||
if output.channel.recv_exit_status() != 0:
|
||||
raise RuntimeError(error.read().decode("utf-8", "replace"))
|
||||
sftp.put(source, destination)
|
||||
sftp.chmod(destination, 0o755 if destination.endswith(".test") else 0o644)
|
||||
if args.command:
|
||||
_, output, error = client.exec_command(args.command, timeout=900)
|
||||
for line in output:
|
||||
print(line, end="", flush=True)
|
||||
errors = error.read().decode("utf-8", "replace")
|
||||
if errors:
|
||||
print(errors, file=sys.stderr)
|
||||
return output.channel.recv_exit_status()
|
||||
return 0
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
28
test/tools/remote_ssh.py
Normal file
28
test/tools/remote_ssh.py
Normal file
@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
def connect_remote(client):
|
||||
host = os.environ.get("SMAIL_SSH_HOST", "47.108.78.12")
|
||||
user = os.environ.get("SMAIL_SSH_USER", "root")
|
||||
password = os.environ.get("SMAIL_SSH_PASSWORD") or None
|
||||
key = os.environ.get("SMAIL_SSH_KEY") or None
|
||||
client.load_system_host_keys()
|
||||
known_hosts = os.environ.get("SMAIL_SSH_KNOWN_HOSTS")
|
||||
if known_hosts:
|
||||
client.load_host_keys(known_hosts)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.connect(
|
||||
host,
|
||||
port=int(os.environ.get("SMAIL_SSH_PORT", "22")),
|
||||
username=user,
|
||||
password=password,
|
||||
key_filename=key,
|
||||
look_for_keys=password is None,
|
||||
allow_agent=password is None,
|
||||
timeout=10,
|
||||
auth_timeout=10,
|
||||
banner_timeout=10,
|
||||
)
|
||||
return client
|
||||
12
test/tools/review_tests.sh
Normal file
12
test/tools/review_tests.sh
Normal file
@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd /var/tmp/smail-review-20260917
|
||||
for name in outbox utils-send utils-smtp controllers-email db-migration res_init services-dbmigration; do
|
||||
if runuser -u smail -- "./$name.test" -test.v -test.timeout=180s > "$name.result.log" 2>&1; then
|
||||
printf '%s PASS\n' "$name"
|
||||
tail -n 3 "$name.result.log"
|
||||
else
|
||||
cat "$name.result.log"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
@ -2,8 +2,9 @@
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
_, out, err = ssh.exec_command('python3 /tmp/check_hex2.py')
|
||||
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''
|
||||
import sqlite3, base64, glob
|
||||
|
||||
@ -32,11 +32,11 @@ with sync_playwright() as p:
|
||||
page.screenshot(path=os.path.join(test_log_dir, 'step2_admin_password.png'))
|
||||
|
||||
# Step 2: 输入管理员密码
|
||||
print('输入管理员密码 10102442Lb!...')
|
||||
print('输入管理员密码 [redacted]...')
|
||||
pwd_inputs = page.locator('.form input')
|
||||
print('密码页输入框数量:', pwd_inputs.count())
|
||||
for i in range(pwd_inputs.count()):
|
||||
pwd_inputs.nth(i).fill('10102442Lb!')
|
||||
pwd_inputs.nth(i).fill(os.environ["SMAIL_TEST_ADMIN_PASSWORD"])
|
||||
|
||||
time.sleep(1)
|
||||
page.screenshot(path=os.path.join(test_log_dir, 'step2_password_filled.png'))
|
||||
|
||||
@ -38,7 +38,7 @@ with sync_playwright() as p:
|
||||
inputs = page.locator('.database-form input, .ctn input')
|
||||
print('找到输入框数量:', inputs.count())
|
||||
for i in range(inputs.count()):
|
||||
inputs.nth(i).fill('10102442Lb!')
|
||||
inputs.nth(i).fill(os.environ["SMAIL_TEST_ADMIN_PASSWORD"])
|
||||
|
||||
time.sleep(1)
|
||||
page.screenshot(path=os.path.join(test_log_dir, 'step2_password_filled.png'))
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
s = '''import smtplib, ssl
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command("echo HELLO")
|
||||
print(stdout:, stdout.read().decode())
|
||||
print(stderr:, stderr.read().decode())
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
stdin, stdout, stderr = ssh.exec_command('python3 -c "import sqlite3; conn=sqlite3.connect(\x27/opt/smail/data/smail.db\x27); c=conn.cursor(); print(c.execute(\x27SELECT id, user_id, email_id, is_read, is_trash FROM user_email ORDER BY id DESC LIMIT 5\x27).fetchall())"')
|
||||
print("User emails top 5:\n", stdout.read().decode())
|
||||
stdin, stdout, stderr = ssh.exec_command('python3 -c "import sqlite3; conn=sqlite3.connect(\x27/opt/smail/data/smail.db\x27); c=conn.cursor(); print(c.execute(\x27SELECT id, subject, send_time FROM email ORDER BY id DESC LIMIT 5\x27).fetchall())"')
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=8)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(client)
|
||||
client.exec_command('systemctl stop smail-init; pkill -9 smail')
|
||||
|
||||
setup_script = '''#!/bin/bash
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
|
||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
from remote_ssh import connect_remote
|
||||
connect_remote(ssh)
|
||||
|
||||
cmd = """python3 -c '
|
||||
import json
|
||||
|
||||
Loading…
Reference in New Issue
Block a user