fix(smtp): make audit persistence atomic (#358)
This commit is contained in:
parent
1da3e178bf
commit
a6049bf052
@ -23,4 +23,7 @@ const (
|
||||
|
||||
// EmailStatusJunk 骚扰邮件
|
||||
EmailStatusJunk int8 = 5
|
||||
|
||||
// EmailStatusDeliveryPending marks an outbound audit whose delivery result is not final.
|
||||
EmailStatusDeliveryPending int8 = 6
|
||||
)
|
||||
|
||||
@ -2,6 +2,8 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/models"
|
||||
"github.com/Jinnrry/pmail/utils/context"
|
||||
@ -9,8 +11,6 @@ import (
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
log "github.com/sirupsen/logrus"
|
||||
_ "modernc.org/sqlite"
|
||||
"time"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
@ -19,20 +19,20 @@ var Instance *xorm.Engine
|
||||
func Init(version string) error {
|
||||
dsn := config.Instance.DbDSN
|
||||
var err error
|
||||
if config.Instance.DbType == config.DBTypeSQLite {
|
||||
dsn, err = sqliteDSNWithReliabilityOptions(dsn)
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
switch config.Instance.DbType {
|
||||
case "mysql":
|
||||
Instance, err = xorm.NewEngine("mysql", dsn)
|
||||
Instance.SetMaxOpenConns(100)
|
||||
Instance.SetMaxIdleConns(10)
|
||||
case "sqlite":
|
||||
Instance, err = xorm.NewEngine("sqlite", dsn)
|
||||
Instance.SetMaxOpenConns(1)
|
||||
Instance.SetMaxIdleConns(1)
|
||||
case "postgres":
|
||||
Instance, err = xorm.NewEngine("postgres", dsn)
|
||||
Instance.SetMaxOpenConns(100)
|
||||
Instance.SetMaxIdleConns(10)
|
||||
default:
|
||||
return errors.New("Database Type Error!")
|
||||
}
|
||||
@ -40,6 +40,13 @@ func Init(version string) error {
|
||||
log.Errorf("DB init Error! %s", err.Error())
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
if config.Instance.DbType == config.DBTypeSQLite {
|
||||
Instance.SetMaxOpenConns(1)
|
||||
Instance.SetMaxIdleConns(1)
|
||||
} else {
|
||||
Instance.SetMaxOpenConns(100)
|
||||
Instance.SetMaxIdleConns(10)
|
||||
}
|
||||
|
||||
Instance.SetConnMaxLifetime(30 * time.Minute)
|
||||
Instance.ShowSQL(config.Instance.LogLevel == "debug")
|
||||
|
||||
174
server/db/retry.go
Normal file
174
server/db/retry.go
Normal file
@ -0,0 +1,174 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"modernc.org/sqlite"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteBusyTimeoutMilliseconds = 1000
|
||||
transactionAttempts = 3
|
||||
transactionInitialBackoff = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// Transaction runs fn atomically. SQLite BUSY and LOCKED failures retry the
|
||||
// entire transaction, so fn must contain only work that is safe to repeat.
|
||||
func Transaction(engine *xorm.Engine, fn func(*xorm.Session) error) error {
|
||||
return transactionWithRetry(engine, transactionAttempts, transactionInitialBackoff, fn)
|
||||
}
|
||||
|
||||
func transactionWithRetry(engine *xorm.Engine, attempts int, initialBackoff time.Duration, fn func(*xorm.Session) error) error {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
var retrySafe bool
|
||||
err, retrySafe = runTransactionAttempt(engine, fn)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !retrySafe || engine.DriverName() != "sqlite" || !IsSQLiteBusyOrLocked(err) || attempt == attempts {
|
||||
return err
|
||||
}
|
||||
time.Sleep(initialBackoff << (attempt - 1))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runTransaction(engine *xorm.Engine, fn func(*xorm.Session) error) error {
|
||||
err, _ := runTransactionAttempt(engine, fn)
|
||||
return err
|
||||
}
|
||||
|
||||
func runTransactionAttempt(engine *xorm.Engine, fn func(*xorm.Session) error) (error, bool) {
|
||||
session := engine.NewSession()
|
||||
defer session.Close()
|
||||
|
||||
if err := session.Begin(); err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err), true
|
||||
}
|
||||
if err := fn(session); err != nil {
|
||||
if rollbackErr := session.Rollback(); rollbackErr != nil {
|
||||
return stderrors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr)), false
|
||||
}
|
||||
return err, true
|
||||
}
|
||||
if err := session.Commit(); err != nil {
|
||||
// modernc.org/sqlite v1.46.1 and newer roll back internally when COMMIT
|
||||
// fails, leaving the connection clean and the transaction safe to retry.
|
||||
return fmt.Errorf("commit transaction: %w", err), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// IsSQLiteBusyOrLocked classifies SQLite errors by result code, including
|
||||
// extended BUSY and LOCKED codes. Error message text is intentionally ignored.
|
||||
func IsSQLiteBusyOrLocked(err error) bool {
|
||||
var sqliteErr *sqlite.Error
|
||||
if !stderrors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
primaryCode := sqliteErr.Code() & 0xff
|
||||
return primaryCode == 5 || primaryCode == 6
|
||||
}
|
||||
|
||||
func sqliteDSNWithReliabilityOptions(dsn string) (string, error) {
|
||||
dsnPrefix, rawQuery, hasQuery := strings.Cut(dsn, "?")
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse SQLite DSN: %w", err)
|
||||
}
|
||||
|
||||
pragmas := query["_pragma"]
|
||||
timeoutIndex := -1
|
||||
for index, pragma := range pragmas {
|
||||
if sqlitePragmaName(pragma) == "busy_timeout" {
|
||||
if timeoutIndex >= 0 {
|
||||
return "", fmt.Errorf("parse SQLite DSN: multiple busy_timeout pragmas are ambiguous")
|
||||
}
|
||||
timeoutIndex = index
|
||||
}
|
||||
}
|
||||
if timeoutIndex >= 0 {
|
||||
requested, err := sqliteBusyTimeoutValue(pragmas[timeoutIndex])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse SQLite busy_timeout pragma %q: %w", pragmas[timeoutIndex], err)
|
||||
}
|
||||
if requested <= sqliteBusyTimeoutMilliseconds {
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
log.Warnf(
|
||||
"SQLite busy_timeout requested %dms exceeds the %dms maximum; using %dms",
|
||||
requested,
|
||||
sqliteBusyTimeoutMilliseconds,
|
||||
sqliteBusyTimeoutMilliseconds,
|
||||
)
|
||||
pragmas[timeoutIndex] = fmt.Sprintf("busy_timeout(%d)", sqliteBusyTimeoutMilliseconds)
|
||||
query["_pragma"] = pragmas
|
||||
return dsnPrefix + "?" + query.Encode(), nil
|
||||
}
|
||||
|
||||
separator := "?"
|
||||
if hasQuery {
|
||||
separator = "&"
|
||||
if strings.HasSuffix(dsn, "?") || strings.HasSuffix(dsn, "&") {
|
||||
separator = ""
|
||||
}
|
||||
}
|
||||
option := url.Values{"_pragma": {fmt.Sprintf("busy_timeout(%d)", sqliteBusyTimeoutMilliseconds)}}.Encode()
|
||||
return dsn + separator + option, nil
|
||||
}
|
||||
|
||||
func sqliteBusyTimeoutValue(pragma string) (int64, error) {
|
||||
const name = "busy_timeout"
|
||||
|
||||
normalized := strings.TrimSpace(pragma)
|
||||
if len(normalized) < len(name) || !strings.EqualFold(normalized[:len(name)], name) {
|
||||
return 0, fmt.Errorf("not a busy_timeout pragma")
|
||||
}
|
||||
remainder := strings.TrimSpace(normalized[len(name):])
|
||||
|
||||
var valueText string
|
||||
switch {
|
||||
case strings.HasPrefix(remainder, "("):
|
||||
if !strings.HasSuffix(remainder, ")") {
|
||||
return 0, fmt.Errorf("expected busy_timeout(<milliseconds>)")
|
||||
}
|
||||
valueText = strings.TrimSpace(remainder[1 : len(remainder)-1])
|
||||
case strings.HasPrefix(remainder, "="):
|
||||
valueText = strings.TrimSpace(remainder[1:])
|
||||
default:
|
||||
return 0, fmt.Errorf("expected busy_timeout(<milliseconds>) or busy_timeout = <milliseconds>")
|
||||
}
|
||||
if valueText == "" {
|
||||
return 0, fmt.Errorf("timeout value is empty")
|
||||
}
|
||||
|
||||
timeout, err := strconv.ParseInt(valueText, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid timeout value %q: %w", valueText, err)
|
||||
}
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
func sqlitePragmaName(pragma string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(pragma))
|
||||
if end := strings.IndexFunc(normalized, func(character rune) bool {
|
||||
return character == '(' || character == '=' || unicode.IsSpace(character)
|
||||
}); end >= 0 {
|
||||
normalized = normalized[:end]
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
407
server/db/retry_test.go
Normal file
407
server/db/retry_test.go
Normal file
@ -0,0 +1,407 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"modernc.org/sqlite"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestSQLiteDSNOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dsn string
|
||||
wantDSN string
|
||||
wantBusyTimeout string
|
||||
wantJournalMode string
|
||||
wantTxLock string
|
||||
}{
|
||||
{
|
||||
name: "plain filename gets default timeout",
|
||||
dsn: "/tmp/pmail.db",
|
||||
wantBusyTimeout: "busy_timeout(1000)",
|
||||
},
|
||||
{
|
||||
name: "timeout at cap is preserved byte for byte",
|
||||
dsn: "file:pmail.db?_txlock=deferred&_pragma=BUSY_TIMEOUT%20%3D%201000&mode=memory",
|
||||
wantDSN: "file:pmail.db?_txlock=deferred&_pragma=BUSY_TIMEOUT%20%3D%201000&mode=memory",
|
||||
wantBusyTimeout: "BUSY_TIMEOUT = 1000",
|
||||
wantTxLock: "deferred",
|
||||
},
|
||||
{
|
||||
name: "explicit timeout is capped and other options are preserved",
|
||||
dsn: "file:pmail.db?mode=memory&_pragma=journal_mode(WAL)&_pragma=busy_timeout%20%3D%20600000&_txlock=deferred",
|
||||
wantBusyTimeout: "busy_timeout(1000)",
|
||||
wantJournalMode: "journal_mode(WAL)",
|
||||
wantTxLock: "deferred",
|
||||
},
|
||||
{
|
||||
name: "negative timeout is preserved byte for byte",
|
||||
dsn: "file:pmail.db?cache=shared&_pragma=%20busy_timeout%20%28%20-1%20%29%20",
|
||||
wantDSN: "file:pmail.db?cache=shared&_pragma=%20busy_timeout%20%28%20-1%20%29%20",
|
||||
wantBusyTimeout: " busy_timeout ( -1 ) ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := sqliteDSNWithReliabilityOptions(tt.dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqliteDSNWithReliabilityOptions() error = %v", err)
|
||||
}
|
||||
if tt.wantDSN != "" && got != tt.wantDSN {
|
||||
t.Errorf("sqliteDSNWithReliabilityOptions() = %q, want byte-for-byte %q", got, tt.wantDSN)
|
||||
}
|
||||
|
||||
queryText := ""
|
||||
if _, after, ok := strings.Cut(got, "?"); ok {
|
||||
queryText = after
|
||||
}
|
||||
query, err := url.ParseQuery(queryText)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseQuery(%q): %v", queryText, err)
|
||||
}
|
||||
if got := query.Get("_txlock"); got != tt.wantTxLock {
|
||||
t.Errorf("_txlock = %q, want %q", got, tt.wantTxLock)
|
||||
}
|
||||
if !containsString(query["_pragma"], tt.wantBusyTimeout) {
|
||||
t.Errorf("_pragma = %q, want %q", query["_pragma"], tt.wantBusyTimeout)
|
||||
}
|
||||
if got := countPragma(query["_pragma"], "busy_timeout"); got != 1 {
|
||||
t.Errorf("busy_timeout pragma count = %d, want 1: %q", got, query["_pragma"])
|
||||
}
|
||||
if tt.wantJournalMode != "" && !containsString(query["_pragma"], tt.wantJournalMode) {
|
||||
t.Errorf("_pragma = %q, want preserved %q", query["_pragma"], tt.wantJournalMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteDSNRejectsInvalidBusyTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dsn string
|
||||
}{
|
||||
{
|
||||
name: "duplicate timeout pragmas",
|
||||
dsn: "file:pmail.db?_pragma=busy_timeout(10)&_pragma=BUSY_TIMEOUT%20%3D%2020",
|
||||
},
|
||||
{
|
||||
name: "missing timeout value",
|
||||
dsn: "file:pmail.db?_pragma=busy_timeout",
|
||||
},
|
||||
{
|
||||
name: "nonnumeric timeout",
|
||||
dsn: "file:pmail.db?_pragma=busy_timeout(forever)",
|
||||
},
|
||||
{
|
||||
name: "timeout with trailing junk",
|
||||
dsn: "file:pmail.db?_pragma=busy_timeout(10)%20milliseconds",
|
||||
},
|
||||
{
|
||||
name: "overflowing timeout",
|
||||
dsn: "file:pmail.db?_pragma=busy_timeout(9223372036854775808)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := sqliteDSNWithReliabilityOptions(tt.dsn); err == nil {
|
||||
t.Fatalf("sqliteDSNWithReliabilityOptions(%q) error = nil, want error", tt.dsn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteDSNWarnsWhenBusyTimeoutIsCapped(t *testing.T) {
|
||||
logger := log.StandardLogger()
|
||||
originalOutput := logger.Out
|
||||
originalLevel := logger.GetLevel()
|
||||
var output bytes.Buffer
|
||||
logger.SetOutput(&output)
|
||||
logger.SetLevel(log.WarnLevel)
|
||||
t.Cleanup(func() {
|
||||
logger.SetOutput(originalOutput)
|
||||
logger.SetLevel(originalLevel)
|
||||
})
|
||||
|
||||
if _, err := sqliteDSNWithReliabilityOptions("file:pmail.db?_pragma=busy_timeout(600000)"); err != nil {
|
||||
t.Fatalf("sqliteDSNWithReliabilityOptions() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"600000ms", "1000ms"} {
|
||||
if !strings.Contains(output.String(), want) {
|
||||
t.Errorf("warning = %q, want it to contain %q", output.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteBusyTimeoutAppliesToEveryConnection(t *testing.T) {
|
||||
dsn, err := sqliteDSNWithReliabilityOptions(filepath.Join(t.TempDir(), "connections.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
database.SetMaxOpenConns(2)
|
||||
|
||||
first, err := database.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
second, err := database.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
|
||||
for index, connection := range []*sql.Conn{first, second} {
|
||||
var timeout int
|
||||
if err = connection.QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||
t.Fatalf("connection %d: %v", index+1, err)
|
||||
}
|
||||
if timeout != sqliteBusyTimeoutMilliseconds {
|
||||
t.Errorf("connection %d busy_timeout = %d, want %d", index+1, timeout, sqliteBusyTimeoutMilliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSQLiteBusyOrLockedUsesDriverCode(t *testing.T) {
|
||||
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "busy.db")+"?_pragma=busy_timeout(1)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.Close()
|
||||
if _, err = engine.Exec("CREATE TABLE audit (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
busyErr := sqliteBusyError(t, engine.DataSourceName())
|
||||
var sqliteErr *sqlite.Error
|
||||
if !errors.As(busyErr, &sqliteErr) {
|
||||
t.Fatalf("error type = %T, want *sqlite.Error", busyErr)
|
||||
}
|
||||
if !IsSQLiteBusyOrLocked(fmt.Errorf("wrapped: %w", busyErr)) {
|
||||
t.Fatalf("IsSQLiteBusyOrLocked(%v) = false", busyErr)
|
||||
}
|
||||
if IsSQLiteBusyOrLocked(errors.New("database is locked")) {
|
||||
t.Fatal("plain error text must not be classified as SQLITE_BUSY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionRetriesWholeSQLiteTransaction(t *testing.T) {
|
||||
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "retry.db")+"?_pragma=busy_timeout(10)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.Close()
|
||||
engine.SetMaxOpenConns(1)
|
||||
if _, err = engine.Exec("CREATE TABLE audit (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
busyErr := sqliteBusyError(t, engine.DataSourceName())
|
||||
|
||||
attempts := 0
|
||||
err = transactionWithRetry(engine, 3, time.Millisecond, func(session *xorm.Session) error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return busyErr
|
||||
}
|
||||
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('saved')")
|
||||
return insertErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transactionWithRetry() error = %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
assertAuditRowCount(t, engine, 1)
|
||||
}
|
||||
|
||||
func TestTransactionRetriesSQLiteBusyCommit(t *testing.T) {
|
||||
engine, blocker, readTx := newCommitBusyTest(t, 10)
|
||||
|
||||
attempts := 0
|
||||
err := transactionWithRetry(engine, 2, 5*time.Millisecond, func(session *xorm.Session) error {
|
||||
attempts++
|
||||
if attempts == 2 {
|
||||
if rollbackErr := readTx.Rollback(); rollbackErr != nil {
|
||||
return fmt.Errorf("release read transaction: %w", rollbackErr)
|
||||
}
|
||||
}
|
||||
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('saved')")
|
||||
return insertErr
|
||||
})
|
||||
_ = blocker.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("transactionWithRetry() error = %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
assertAuditRowCount(t, engine, 2)
|
||||
}
|
||||
|
||||
func TestPermanentSQLiteBusyCommitLeavesConnectionUsable(t *testing.T) {
|
||||
engine, blocker, readTx := newCommitBusyTest(t, 5)
|
||||
|
||||
attempts := 0
|
||||
err := transactionWithRetry(engine, 2, time.Millisecond, func(session *xorm.Session) error {
|
||||
attempts++
|
||||
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('rolled back')")
|
||||
return insertErr
|
||||
})
|
||||
if err == nil || !IsSQLiteBusyOrLocked(err) {
|
||||
t.Fatalf("transactionWithRetry() error = %v, want SQLITE_BUSY", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
if err = readTx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = blocker.Close()
|
||||
|
||||
err = runTransaction(engine, func(session *xorm.Session) error {
|
||||
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('after busy')")
|
||||
return insertErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transaction after BUSY = %v", err)
|
||||
}
|
||||
assertAuditRowCount(t, engine, 2)
|
||||
}
|
||||
|
||||
func TestTransactionDoesNotClassifyBusyText(t *testing.T) {
|
||||
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "text.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.Close()
|
||||
|
||||
attempts := 0
|
||||
wantErr := errors.New("database is locked")
|
||||
err = transactionWithRetry(engine, 3, time.Millisecond, func(*xorm.Session) error {
|
||||
attempts++
|
||||
return wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("transactionWithRetry() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func newCommitBusyTest(t *testing.T, timeoutMilliseconds int) (*xorm.Engine, *sql.DB, *sql.Tx) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "commit-busy.db")
|
||||
dsn := fmt.Sprintf("%s?_pragma=busy_timeout(%d)", path, timeoutMilliseconds)
|
||||
engine, err := xorm.NewEngine("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = engine.Close() })
|
||||
engine.SetMaxOpenConns(1)
|
||||
engine.SetMaxIdleConns(1)
|
||||
if _, err = engine.Exec("CREATE TABLE audit (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = engine.Exec("INSERT INTO audit(value) VALUES ('seed')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocker, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readTx, err := blocker.Begin()
|
||||
if err != nil {
|
||||
_ = blocker.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err = readTx.QueryRow("SELECT count(*) FROM audit").Scan(&count); err != nil {
|
||||
_ = readTx.Rollback()
|
||||
_ = blocker.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = readTx.Rollback()
|
||||
_ = blocker.Close()
|
||||
})
|
||||
return engine, blocker, readTx
|
||||
}
|
||||
|
||||
func sqliteBusyError(t *testing.T, dsn string) error {
|
||||
t.Helper()
|
||||
first, err := xorm.NewEngine("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
second, err := xorm.NewEngine("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
|
||||
tx := first.NewSession()
|
||||
defer tx.Close()
|
||||
if err = tx.Begin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO audit(value) VALUES ('rolled back')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, busyErr := second.Exec("INSERT INTO audit(value) VALUES ('blocked')")
|
||||
_ = tx.Rollback()
|
||||
if busyErr == nil {
|
||||
t.Fatal("expected SQLITE_BUSY")
|
||||
}
|
||||
return busyErr
|
||||
}
|
||||
|
||||
func assertAuditRowCount(t *testing.T, engine *xorm.Engine, want int) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if _, err := engine.SQL("SELECT count(*) FROM audit").Get(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("audit rows = %d, want %d", count, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countPragma(values []string, name string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if sqlitePragmaName(value) == name {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@ -21,9 +21,9 @@ require (
|
||||
github.com/mileusna/spf v0.9.5
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cast v1.7.1
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/text v0.24.0
|
||||
modernc.org/sqlite v1.37.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/text v0.30.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.3.9
|
||||
)
|
||||
@ -43,20 +43,20 @@ require (
|
||||
github.com/miekg/dns v1.1.65 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nxadm/tail v1.4.11 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
github.com/onsi/gomega v1.31.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
modernc.org/libc v1.63.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@ -74,6 +74,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
@ -101,8 +103,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
@ -143,16 +145,16 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@ -163,16 +165,16 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@ -194,8 +196,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@ -205,16 +207,16 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@ -236,26 +238,30 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
|
||||
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
215
server/listen/smtp_server/audit_persistence.go
Normal file
215
server/listen/smtp_server/audit_persistence.go
Normal file
@ -0,0 +1,215 @@
|
||||
package smtp_server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/consts"
|
||||
"github.com/Jinnrry/pmail/db"
|
||||
"github.com/Jinnrry/pmail/dto/parsemail"
|
||||
"github.com/Jinnrry/pmail/hooks"
|
||||
"github.com/Jinnrry/pmail/hooks/framework"
|
||||
"github.com/Jinnrry/pmail/models"
|
||||
"github.com/Jinnrry/pmail/utils/array"
|
||||
"github.com/Jinnrry/pmail/utils/async"
|
||||
"github.com/Jinnrry/pmail/utils/context"
|
||||
smtp "github.com/emersion/go-smtp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
const deliveryPendingError = "delivery result pending; outcome unknown after interruption"
|
||||
|
||||
type outgoingSender func(*context.Context, *parsemail.Email) (error, map[string]error)
|
||||
|
||||
func (s *Session) deliverOutgoingEmail(email *parsemail.Email, sender outgoingSender) error {
|
||||
ctx := s.Ctx
|
||||
_, saved, err := saveEmail(ctx, email.Size, email, ctx.UserID, int(consts.EmailTypeSend), nil, true, true)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("CRITICAL audit_event=initial_persist_failed direction=outbound msg_id=%q error=%v", email.MsgID, err)
|
||||
return localPersistenceSMTPError()
|
||||
}
|
||||
|
||||
deliveryErr, domainErrors := sender(ctx, email)
|
||||
runSendAfterHooks(ctx, email, domainErrors)
|
||||
|
||||
targetStatus := consts.EmailStatusSent
|
||||
var errorText any
|
||||
if deliveryErr != nil {
|
||||
targetStatus = consts.EmailStatusFail
|
||||
errorText = deliveryErr.Error()
|
||||
}
|
||||
|
||||
if err = persistDeliveryResult(ctx, saved.Id, targetStatus, errorText); err != nil {
|
||||
log.WithContext(ctx).Errorf(
|
||||
"CRITICAL audit_event=delivery_result_persist_failed email_id=%d msg_id=%q target_status=%d downstream_accepted=%t error=%v",
|
||||
saved.Id, saved.MsgID, targetStatus, deliveryErr == nil, err,
|
||||
)
|
||||
}
|
||||
|
||||
// If the remote MX accepted DATA, returning a local 451 here would cause a
|
||||
// duplicate delivery. The persistence failure is retained as pending state
|
||||
// and a CRITICAL log; the SMTP result must still reflect the remote result.
|
||||
return downstreamDeliverySMTPError(deliveryErr, domainErrors)
|
||||
}
|
||||
|
||||
func runSendAfterHooks(ctx *context.Context, email *parsemail.Email, domainErrors map[string]error) {
|
||||
process := async.New(ctx)
|
||||
for _, hook := range hooks.HookList {
|
||||
if hook == nil {
|
||||
continue
|
||||
}
|
||||
process.WaitProcess(func(value any) {
|
||||
value.(framework.EmailHook).SendAfter(ctx, email, domainErrors)
|
||||
}, hook)
|
||||
}
|
||||
process.Wait()
|
||||
}
|
||||
|
||||
func persistDeliveryResult(ctx *context.Context, emailID int, status int8, errorText any) error {
|
||||
return db.Transaction(db.Instance, func(session *xorm.Session) error {
|
||||
result, err := session.Exec(db.WithContext(ctx, `update email
|
||||
set status=?, error=?, update_time=CURRENT_TIMESTAMP
|
||||
where id=?`), status, errorText, emailID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = requireOneAffected(result, "update email delivery result"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err = session.Exec(db.WithContext(ctx, `update user_email
|
||||
set status=? where email_id=?`), status, emailID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneAffected(result, "update user_email delivery result")
|
||||
})
|
||||
}
|
||||
|
||||
func requireOneAffected(result sql.Result, operation string) error {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read affected rows: %w", operation, err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return fmt.Errorf("%s: affected %d rows, want 1", operation, affected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func localPersistenceSMTPError() error {
|
||||
return &smtp.SMTPError{
|
||||
Code: 451,
|
||||
EnhancedCode: smtp.EnhancedCode{4, 3, 0},
|
||||
Message: "Temporary local persistence failure",
|
||||
}
|
||||
}
|
||||
|
||||
func persistInitialAudit(ctx *context.Context, modelEmail models.Email, userIDs []int) (*models.Email, error) {
|
||||
var saved models.Email
|
||||
err := db.Transaction(db.Instance, func(session *xorm.Session) error {
|
||||
candidate := modelEmail
|
||||
candidate.Id = 0
|
||||
if _, err := session.Insert(&candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
if candidate.Id <= 0 {
|
||||
return stderrors.New("email insert returned no id")
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
relation := models.UserEmail{EmailID: candidate.Id, UserID: userID, Status: candidate.Status}
|
||||
if _, err := session.Insert(&relation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
saved = candidate
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &saved, nil
|
||||
}
|
||||
|
||||
func resolveIncomingUsers(ctx *context.Context, email *parsemail.Email, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, bool, error) {
|
||||
if emailType != int(consts.EmailTypeReceive) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
accounts := incomingAccounts(email, reallyTo)
|
||||
var users []*models.User
|
||||
if len(accounts) > 0 {
|
||||
where, params, err := builder.ToSQL(builder.In("LOWER(account)", accounts))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err = db.Instance.Table(&models.User{}).Where(where, params...).Find(&users); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
if len(users) > 0 {
|
||||
return users, false, nil
|
||||
}
|
||||
|
||||
if DropUnknownRecipientEmails &&
|
||||
((config.Instance.SpamFilterLevel == 1 && !SPFStatus && !dkimStatus) ||
|
||||
(config.Instance.SpamFilterLevel == 2 && !SPFStatus) ||
|
||||
(config.Instance.SpamFilterLevel == 3 && !dkimStatus)) {
|
||||
log.WithContext(ctx).Infoln("垃圾邮件,拒信")
|
||||
log.WithContext(ctx).Infof("收件人不存在且DKIM验证失败,丢弃邮件: %s -> %v", email.From.EmailAddress, accounts)
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("收件人不存在但DKIM验证通过,转交管理员: %s -> %v", email.From.EmailAddress, accounts)
|
||||
if err := db.Instance.Table(&models.User{}).Where("is_admin=1").Find(&users); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return nil, false, stderrors.New("no administrator available for unknown recipient")
|
||||
}
|
||||
return users, false, nil
|
||||
}
|
||||
|
||||
func incomingAccounts(email *parsemail.Email, reallyTo []string) []string {
|
||||
var accounts []string
|
||||
if len(reallyTo) > 0 {
|
||||
for _, recipient := range reallyTo {
|
||||
account := parsemail.BuilderUser(recipient)
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
name, domain := account.GetDomainAccount()
|
||||
if array.InArray(domain, config.Instance.Domains) && name != "" {
|
||||
accounts = append(accounts, strings.ToLower(name))
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
for _, recipients := range [][]*parsemail.User{email.To, email.Cc, email.Bcc} {
|
||||
for _, user := range recipients {
|
||||
account, _ := user.GetDomainAccount()
|
||||
if account != "" {
|
||||
accounts = append(accounts, strings.ToLower(account))
|
||||
}
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func boolInt8(value bool) int8 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func json2string(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
return string(data)
|
||||
}
|
||||
562
server/listen/smtp_server/audit_persistence_test.go
Normal file
562
server/listen/smtp_server/audit_persistence_test.go
Normal file
@ -0,0 +1,562 @@
|
||||
package smtp_server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/consts"
|
||||
"github.com/Jinnrry/pmail/db"
|
||||
"github.com/Jinnrry/pmail/dto"
|
||||
"github.com/Jinnrry/pmail/dto/parsemail"
|
||||
"github.com/Jinnrry/pmail/hooks"
|
||||
"github.com/Jinnrry/pmail/hooks/framework"
|
||||
"github.com/Jinnrry/pmail/models"
|
||||
listservice "github.com/Jinnrry/pmail/services/list"
|
||||
"github.com/Jinnrry/pmail/utils/context"
|
||||
smtp "github.com/emersion/go-smtp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
_ "modernc.org/sqlite"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestOutgoingAuditInsertFailureDoesNotDeliver(t *testing.T) {
|
||||
engine, dsn := newAuditTestEngine(t)
|
||||
blocker := beginAuditReadTransaction(t, dsn)
|
||||
|
||||
var deliveries atomic.Int32
|
||||
session := newAuditTestSession()
|
||||
err := session.deliverOutgoingEmail(newAuditTestEmail("insert-busy@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
assertSMTPCode(t, err, 451)
|
||||
if got := deliveries.Load(); got != 0 {
|
||||
t.Fatalf("downstream deliveries = %d, want 0", got)
|
||||
}
|
||||
assertAuditCounts(t, engine, 0, 0)
|
||||
|
||||
if err := blocker.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := session.deliverOutgoingEmail(newAuditTestEmail("insert-busy@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("delivery after lock release failed: %v", err)
|
||||
}
|
||||
if got := deliveries.Load(); got != 1 {
|
||||
t.Fatalf("downstream deliveries after recovery = %d, want 1", got)
|
||||
}
|
||||
assertAuditCounts(t, engine, 1, 1)
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusSent)
|
||||
}
|
||||
|
||||
func TestOutgoingAuditRetriesBusyInitialInsertBeforeDeliver(t *testing.T) {
|
||||
engine, dsn := newAuditTestEngine(t)
|
||||
blocker := beginAuditReadTransaction(t, dsn)
|
||||
released := make(chan struct{})
|
||||
go func() {
|
||||
time.Sleep(35 * time.Millisecond)
|
||||
_ = blocker.Rollback()
|
||||
close(released)
|
||||
}()
|
||||
|
||||
var deliveries atomic.Int32
|
||||
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("initial-retry@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
return nil, nil
|
||||
})
|
||||
<-released
|
||||
if err != nil {
|
||||
t.Fatalf("deliverOutgoingEmail() error = %v", err)
|
||||
}
|
||||
if got := deliveries.Load(); got != 1 {
|
||||
t.Fatalf("downstream deliveries = %d, want 1", got)
|
||||
}
|
||||
assertAuditCounts(t, engine, 1, 1)
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusSent)
|
||||
}
|
||||
|
||||
func TestOutgoingAuditRelationInsertRollsBackEmail(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
if _, err := engine.Exec(`CREATE TRIGGER fail_user_email_insert
|
||||
BEFORE INSERT ON user_email
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced user_email insert failure');
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
email := newAuditTestEmail("relation-failure@example.com")
|
||||
_, _, err := saveEmail(newAuditTestSession().Ctx, email.Size, email, 1, int(consts.EmailTypeSend), nil, true, true)
|
||||
if err == nil {
|
||||
t.Fatal("saveEmail() error = nil, want relation insert failure")
|
||||
}
|
||||
if email.MessageId != 0 {
|
||||
t.Fatalf("email.MessageId = %d, want 0 after rollback", email.MessageId)
|
||||
}
|
||||
assertAuditCounts(t, engine, 0, 0)
|
||||
}
|
||||
|
||||
func TestIncomingAuditRelationInsertRollsBackEmail(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
recipient := &models.User{Account: "recipient", Name: "Recipient"}
|
||||
if _, err := engine.Insert(recipient); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.Exec(`CREATE TRIGGER fail_incoming_user_email_insert
|
||||
BEFORE INSERT ON user_email
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced incoming user_email insert failure');
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
from := &parsemail.User{Name: "External", EmailAddress: "external@example.net"}
|
||||
email := &parsemail.Email{
|
||||
From: from,
|
||||
Sender: from,
|
||||
To: []*parsemail.User{{EmailAddress: "recipient@example.com"}},
|
||||
Text: []byte("body"),
|
||||
Size: 4,
|
||||
MsgID: "incoming-relation-failure@example.net",
|
||||
}
|
||||
_, _, err := saveEmail(&context.Context{}, email.Size, email, 0, int(consts.EmailTypeReceive), []string{"recipient@example.com"}, true, true)
|
||||
if err == nil {
|
||||
t.Fatal("saveEmail() error = nil, want incoming relation insert failure")
|
||||
}
|
||||
assertAuditCounts(t, engine, 0, 0)
|
||||
}
|
||||
|
||||
func TestOutgoingAuditRetriesBusyFinalUpdateAtomically(t *testing.T) {
|
||||
engine, dsn := newAuditTestEngine(t)
|
||||
var blocker *sql.Tx
|
||||
|
||||
session := newAuditTestSession()
|
||||
err := session.deliverOutgoingEmail(newAuditTestEmail("final-retry@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
blocker = beginAuditReadTransaction(t, dsn)
|
||||
go func() {
|
||||
time.Sleep(35 * time.Millisecond)
|
||||
_ = blocker.Rollback()
|
||||
}()
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deliverOutgoingEmail() error = %v", err)
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusSent)
|
||||
assertAuditError(t, engine, "", false)
|
||||
}
|
||||
|
||||
func TestOutgoingAuditFinalUpdateIsAtomic(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
if _, err := engine.Exec(fmt.Sprintf(`CREATE TRIGGER fail_user_email_success
|
||||
BEFORE UPDATE OF status ON user_email
|
||||
WHEN NEW.status = %d
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced user_email update failure');
|
||||
END`, consts.EmailStatusSent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session := newAuditTestSession()
|
||||
err := session.deliverOutgoingEmail(newAuditTestEmail("atomic-update@example.com"), successfulAuditSender)
|
||||
if err != nil {
|
||||
t.Fatalf("accepted delivery must remain successful, got %v", err)
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
|
||||
assertAuditError(t, engine, deliveryPendingError, true)
|
||||
}
|
||||
|
||||
func TestAcceptedDeliveryWithPermanentAuditLockReturnsSuccessAndAlerts(t *testing.T) {
|
||||
engine, dsn := newAuditTestEngine(t)
|
||||
var blocker *sql.Tx
|
||||
var deliveries atomic.Int32
|
||||
|
||||
oldOutput := log.StandardLogger().Out
|
||||
oldLevel := log.GetLevel()
|
||||
var logs bytes.Buffer
|
||||
log.SetOutput(&logs)
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(oldOutput)
|
||||
log.SetLevel(oldLevel)
|
||||
})
|
||||
|
||||
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("accepted-unknown@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
blocker = beginAuditReadTransaction(t, dsn)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("accepted delivery must remain successful, got %v", err)
|
||||
}
|
||||
if got := deliveries.Load(); got != 1 {
|
||||
t.Fatalf("downstream deliveries = %d, want 1", got)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "audit_event=delivery_result_persist_failed") {
|
||||
t.Fatalf("missing CRITICAL persistence alert in logs: %s", logs.String())
|
||||
}
|
||||
if err = blocker.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
|
||||
assertAuditError(t, engine, deliveryPendingError, true)
|
||||
}
|
||||
|
||||
func TestTemporaryDeliveryFailureWithUnpersistedResultCanRetryAsNewAttempt(t *testing.T) {
|
||||
engine, dsn := newAuditTestEngine(t)
|
||||
var blocker *sql.Tx
|
||||
var deliveries atomic.Int32
|
||||
session := newAuditTestSession()
|
||||
messageID := "temporary-unpersisted@example.com"
|
||||
|
||||
err := session.deliverOutgoingEmail(newAuditTestEmail(messageID), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
blocker = beginAuditReadTransaction(t, dsn)
|
||||
return errors.New("delivery failed"), map[string]error{
|
||||
"example.net": &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")},
|
||||
}
|
||||
})
|
||||
assertSMTPCode(t, err, 451)
|
||||
if err = blocker.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = session.deliverOutgoingEmail(newAuditTestEmail(messageID), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("retry after temporary failure: %v", err)
|
||||
}
|
||||
if got := deliveries.Load(); got != 2 {
|
||||
t.Fatalf("downstream deliveries = %d, want 2", got)
|
||||
}
|
||||
assertAuditCounts(t, engine, 2, 2)
|
||||
assertMessageIDCount(t, engine, messageID, 2)
|
||||
}
|
||||
|
||||
func TestOutgoingAuditPersistsDeliveryFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
domainErr error
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "temporary",
|
||||
domainErr: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")},
|
||||
wantCode: 451,
|
||||
},
|
||||
{
|
||||
name: "permanent",
|
||||
domainErr: &textproto.Error{Code: 550, Msg: "mailbox unavailable"},
|
||||
wantCode: 550,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
deliveryErr := errors.New("delivery failed")
|
||||
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail(tt.name+"-failure@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
return deliveryErr, map[string]error{"example.net": tt.domainErr}
|
||||
})
|
||||
assertSMTPCode(t, err, tt.wantCode)
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusFail)
|
||||
assertAuditError(t, engine, deliveryErr.Error(), true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentDeliveryFailureSurvivesFinalAuditLock(t *testing.T) {
|
||||
engine, dsn := newAuditTestEngine(t)
|
||||
var blocker *sql.Tx
|
||||
err := newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("permanent-failure@example.com"), func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
blocker = beginAuditReadTransaction(t, dsn)
|
||||
failure := &textproto.Error{Code: 550, Msg: "mailbox unavailable"}
|
||||
return errors.New("delivery failed"), map[string]error{"example.net": failure}
|
||||
})
|
||||
assertSMTPCode(t, err, 550)
|
||||
if err = blocker.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
|
||||
assertAuditError(t, engine, deliveryPendingError, true)
|
||||
}
|
||||
|
||||
func TestSameMessageIDCreatesIndependentSubmissions(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
var deliveries atomic.Int32
|
||||
sender := func(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
deliveries.Add(1)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
session := newAuditTestSession()
|
||||
for i := 0; i < 2; i++ {
|
||||
email := newAuditTestEmail("reused-message-id@example.com")
|
||||
email.Text = []byte(fmt.Sprintf("body %d", i))
|
||||
if err := session.deliverOutgoingEmail(email, sender); err != nil {
|
||||
t.Fatalf("submission %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if got := deliveries.Load(); got != 2 {
|
||||
t.Fatalf("downstream deliveries = %d, want 2", got)
|
||||
}
|
||||
assertAuditCounts(t, engine, 2, 2)
|
||||
assertMessageIDCount(t, engine, "reused-message-id@example.com", 2)
|
||||
}
|
||||
|
||||
func TestSendAfterHookCompletesBeforeFinalPersistAndSMTPResult(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
hook := &blockingSendAfterHook{
|
||||
authenticationCaptureHook: &authenticationCaptureHook{},
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
hooks.HookList = map[string]framework.EmailHook{"blocking": hook}
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- newAuditTestSession().deliverOutgoingEmail(newAuditTestEmail("sync-hook@example.com"), successfulAuditSender)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-hook.release:
|
||||
default:
|
||||
close(hook.release)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-hook.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("SendAfter hook was not started")
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
t.Fatalf("delivery returned before SendAfter hook completed: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(hook.release)
|
||||
|
||||
select {
|
||||
case <-hook.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("SendAfter hook did not finish")
|
||||
}
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatalf("deliverOutgoingEmail() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("delivery did not return after SendAfter hook completed")
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusSent)
|
||||
}
|
||||
|
||||
func TestOutgoingPendingAuditMailboxVisibility(t *testing.T) {
|
||||
engine, _ := newAuditTestEngine(t)
|
||||
session := newAuditTestSession()
|
||||
email := newAuditTestEmail("pending-visibility@example.com")
|
||||
_, saved, err := saveEmail(session.Ctx, email.Size, email, session.Ctx.UserID, int(consts.EmailTypeSend), nil, true, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != consts.EmailStatusDeliveryPending {
|
||||
t.Fatalf("saved status = %d, want %d", saved.Status, consts.EmailStatusDeliveryPending)
|
||||
}
|
||||
assertAuditStatuses(t, engine, consts.EmailStatusDeliveryPending)
|
||||
|
||||
outgoing, _ := listservice.GetEmailList(session.Ctx, dto.SearchTag{
|
||||
Type: consts.EmailTypeSend,
|
||||
Status: -1,
|
||||
GroupId: -1,
|
||||
}, "", false, 0, 10)
|
||||
found := false
|
||||
for _, item := range outgoing {
|
||||
if item.Id == saved.Id {
|
||||
found = true
|
||||
if item.Status != consts.EmailStatusDeliveryPending || !item.Error.Valid || item.Error.String != deliveryPendingError {
|
||||
t.Fatalf("outgoing pending audit = status:%d error:%#v", item.Status, item.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("pending audit email %d is missing from web outgoing list", saved.Id)
|
||||
}
|
||||
|
||||
for _, mailbox := range []string{"INBOX", "Sent Messages"} {
|
||||
for _, item := range listservice.GetUEListByUID(session.Ctx, mailbox, 0, 0, nil) {
|
||||
if item.EmailID == saved.Id {
|
||||
t.Fatalf("pending audit email %d unexpectedly appears in IMAP mailbox %q", saved.Id, mailbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type blockingSendAfterHook struct {
|
||||
*authenticationCaptureHook
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (h *blockingSendAfterHook) SendAfter(*context.Context, *parsemail.Email, map[string]error) {
|
||||
defer close(h.done)
|
||||
close(h.started)
|
||||
<-h.release
|
||||
}
|
||||
|
||||
func newAuditTestEngine(t *testing.T) (*xorm.Engine, string) {
|
||||
t.Helper()
|
||||
dsn := filepath.Join(t.TempDir(), "audit.db") + "?_pragma=busy_timeout(20)"
|
||||
engine, err := xorm.NewEngine("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.SetMaxOpenConns(1)
|
||||
engine.SetMaxIdleConns(1)
|
||||
if err = engine.Sync2(&models.User{}, &models.Email{}, &models.UserEmail{}); err != nil {
|
||||
engine.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = engine.Insert(&models.Email{Subject: "lock seed", MsgID: "lock-seed@example.com"}); err != nil {
|
||||
engine.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = engine.Insert(&models.UserEmail{UserID: 99, EmailID: 1}); err != nil {
|
||||
engine.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldDB := db.Instance
|
||||
oldConfig := config.Instance
|
||||
oldHooks := hooks.HookList
|
||||
db.Instance = engine
|
||||
config.Instance = &config.Config{DbType: config.DBTypeSQLite, Domain: "example.com", Domains: []string{"example.com"}}
|
||||
hooks.HookList = nil
|
||||
t.Cleanup(func() {
|
||||
db.Instance = oldDB
|
||||
config.Instance = oldConfig
|
||||
hooks.HookList = oldHooks
|
||||
_ = engine.Close()
|
||||
})
|
||||
return engine, dsn
|
||||
}
|
||||
|
||||
func beginAuditReadTransaction(t *testing.T, dsn string) *sql.Tx {
|
||||
t.Helper()
|
||||
blocker, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = blocker.Close() })
|
||||
tx, err := blocker.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err = tx.QueryRow("SELECT count(*) FROM email").Scan(&count); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func newAuditTestSession() *Session {
|
||||
return &Session{Ctx: &context.Context{UserID: 1, UserAccount: "sender", IsAdmin: true}}
|
||||
}
|
||||
|
||||
func newAuditTestEmail(messageID string) *parsemail.Email {
|
||||
from := &parsemail.User{Name: "Sender", EmailAddress: "sender@example.com"}
|
||||
return &parsemail.Email{
|
||||
From: from,
|
||||
Sender: from,
|
||||
To: []*parsemail.User{{EmailAddress: "recipient@example.net"}},
|
||||
Subject: "audit reliability",
|
||||
Text: []byte("body"),
|
||||
Size: 4,
|
||||
MsgID: messageID,
|
||||
}
|
||||
}
|
||||
|
||||
func successfulAuditSender(*context.Context, *parsemail.Email) (error, map[string]error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func assertSMTPCode(t *testing.T, err error, want int) {
|
||||
t.Helper()
|
||||
var smtpErr *smtp.SMTPError
|
||||
if !errors.As(err, &smtpErr) {
|
||||
t.Fatalf("error = %v (%T), want *smtp.SMTPError", err, err)
|
||||
}
|
||||
if smtpErr.Code != want {
|
||||
t.Fatalf("SMTP code = %d, want %d", smtpErr.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuditCounts(t *testing.T, engine *xorm.Engine, wantEmail, wantUserEmail int) {
|
||||
t.Helper()
|
||||
var emailCount, userEmailCount int
|
||||
if _, err := engine.SQL("SELECT count(*) FROM email WHERE id != 1").Get(&emailCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.SQL("SELECT count(*) FROM user_email WHERE email_id != 1").Get(&userEmailCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if emailCount != wantEmail || userEmailCount != wantUserEmail {
|
||||
t.Fatalf("audit counts = email:%d user_email:%d, want email:%d user_email:%d", emailCount, userEmailCount, wantEmail, wantUserEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuditStatuses(t *testing.T, engine *xorm.Engine, want int8) {
|
||||
t.Helper()
|
||||
var emailStatus, userEmailStatus int8
|
||||
if _, err := engine.SQL("SELECT status FROM email WHERE id != 1 ORDER BY id DESC LIMIT 1").Get(&emailStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.SQL("SELECT status FROM user_email WHERE email_id != 1 ORDER BY email_id DESC LIMIT 1").Get(&userEmailStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if emailStatus != want || userEmailStatus != want {
|
||||
t.Fatalf("audit statuses = email:%d user_email:%d, want %d", emailStatus, userEmailStatus, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuditError(t *testing.T, engine *xorm.Engine, want string, wantValid bool) {
|
||||
t.Helper()
|
||||
var got sql.NullString
|
||||
if _, err := engine.SQL("SELECT error FROM email WHERE id != 1 ORDER BY id DESC LIMIT 1").Get(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String != want || got.Valid != wantValid {
|
||||
t.Fatalf("audit error = %#v, want string %q valid %t", got, want, wantValid)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMessageIDCount(t *testing.T, engine *xorm.Engine, messageID string, want int) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if _, err := engine.SQL("SELECT count(*) FROM email WHERE msg_id=?", messageID).Get(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("rows for Message-ID %q = %d, want %d", messageID, count, want)
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@ package smtp_server
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
oerrors "errors"
|
||||
"io"
|
||||
"net"
|
||||
@ -12,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/consts"
|
||||
"github.com/Jinnrry/pmail/db"
|
||||
"github.com/Jinnrry/pmail/dto/parsemail"
|
||||
"github.com/Jinnrry/pmail/hooks"
|
||||
@ -26,7 +26,6 @@ import (
|
||||
"github.com/mileusna/spf"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cast"
|
||||
. "xorm.io/builder"
|
||||
)
|
||||
|
||||
// DropUnknownRecipientEmails 是代码级别的功能开关
|
||||
@ -92,52 +91,7 @@ func (s *Session) Data(r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 转发
|
||||
_, _, err := saveEmail(ctx, len(emailData), email, s.Ctx.UserID, 1, nil, true, true)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("Email Save Error %v", err)
|
||||
}
|
||||
|
||||
errMsg := ""
|
||||
deliveryErr, sendErr := send.Send(ctx, email)
|
||||
|
||||
log.WithContext(ctx).Debugf("插件执行--SendAfter")
|
||||
|
||||
as3 := async.New(ctx)
|
||||
for _, hook := range hooks.HookList {
|
||||
if hook == nil {
|
||||
continue
|
||||
}
|
||||
as3.WaitProcess(func(hk any) {
|
||||
hk.(framework.EmailHook).SendAfter(ctx, email, sendErr)
|
||||
}, hook)
|
||||
}
|
||||
as3.Wait()
|
||||
log.WithContext(ctx).Debugf("插件执行--SendAfter")
|
||||
|
||||
if deliveryErr != nil {
|
||||
errMsg = deliveryErr.Error()
|
||||
_, err := db.Instance.Exec(db.WithContext(ctx, "update email set status =2 ,error=? where id = ? "), errMsg, email.MessageId)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("sql Error :%+v", err)
|
||||
}
|
||||
_, err = db.Instance.Exec(db.WithContext(ctx, "update user_email set status =2 where email_id = ? "), email.MessageId)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("sql Error :%+v", err)
|
||||
}
|
||||
|
||||
return downstreamDeliverySMTPError(deliveryErr, sendErr)
|
||||
|
||||
} else {
|
||||
_, err := db.Instance.Exec(db.WithContext(ctx, "update email set status =1 where id = ? "), email.MessageId)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("sql Error :%+v", err)
|
||||
}
|
||||
_, err = db.Instance.Exec(db.WithContext(ctx, "update user_email set status =1 where email_id = ? "), email.MessageId)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("sql Error :%+v", err)
|
||||
}
|
||||
}
|
||||
return s.deliverOutgoingEmail(email, send.Send)
|
||||
|
||||
} else {
|
||||
// 收件
|
||||
@ -170,7 +124,11 @@ func (s *Session) Data(r io.Reader) error {
|
||||
email.Status = 3
|
||||
}
|
||||
|
||||
users, dbEmail, _ := saveEmail(ctx, len(emailData), email, 0, 0, s.To, SPFStatus, dkimStatus)
|
||||
users, dbEmail, saveErr := saveEmail(ctx, len(emailData), email, 0, 0, s.To, SPFStatus, dkimStatus)
|
||||
if saveErr != nil {
|
||||
log.WithContext(ctx).Errorf("CRITICAL audit_event=initial_persist_failed direction=inbound msg_id=%q error=%v", email.MsgID, saveErr)
|
||||
return localPersistenceSMTPError()
|
||||
}
|
||||
|
||||
if email.MessageId > 0 {
|
||||
log.WithContext(ctx).Debugf("开始执行邮件规则!")
|
||||
@ -214,28 +172,29 @@ func (s *Session) Data(r io.Reader) error {
|
||||
}
|
||||
|
||||
func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) {
|
||||
if emailType == 0 && email != nil && email.Authentication != nil {
|
||||
if email == nil {
|
||||
return nil, nil, oerrors.New("email is missing")
|
||||
}
|
||||
if email.From == nil {
|
||||
return nil, nil, oerrors.New("email sender is missing")
|
||||
}
|
||||
if emailType == int(consts.EmailTypeReceive) && email.Authentication != nil {
|
||||
SPFStatus = email.Authentication.SPFPassed
|
||||
dkimStatus = email.Authentication.DKIMPassed
|
||||
}
|
||||
var dkimV, spfV int8
|
||||
if dkimStatus {
|
||||
dkimV = 1
|
||||
}
|
||||
if SPFStatus {
|
||||
spfV = 1
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("开始入库!")
|
||||
|
||||
if email == nil {
|
||||
return nil, nil, nil
|
||||
users, drop, err := resolveIncomingUsers(ctx, email, emailType, reallyTo, SPFStatus, dkimStatus)
|
||||
if err != nil || drop {
|
||||
return users, nil, err
|
||||
}
|
||||
|
||||
msgID := email.MsgID
|
||||
if msgID == "" {
|
||||
msgID = parsemail.GenerateMsgID(config.Instance.Domain)
|
||||
}
|
||||
if email.Size == 0 {
|
||||
email.Size = size
|
||||
}
|
||||
|
||||
modelEmail := models.Email{
|
||||
Type: cast.ToInt8(emailType),
|
||||
@ -251,8 +210,8 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
Sender: json2string(email.Sender),
|
||||
Attachments: json2string(email.Attachments),
|
||||
Size: email.Size,
|
||||
SPFCheck: spfV,
|
||||
DKIMCheck: dkimV,
|
||||
SPFCheck: boolInt8(SPFStatus),
|
||||
DKIMCheck: boolInt8(dkimStatus),
|
||||
SendUserID: sendUserID,
|
||||
SendDate: time.Now(),
|
||||
Status: cast.ToInt8(email.Status),
|
||||
@ -261,108 +220,25 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
MsgID: msgID,
|
||||
}
|
||||
|
||||
_, err := db.Instance.Insert(&modelEmail)
|
||||
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
|
||||
}
|
||||
|
||||
if modelEmail.Id > 0 {
|
||||
email.MessageId = cast.ToInt64(modelEmail.Id)
|
||||
email.MsgID = modelEmail.MsgID
|
||||
}
|
||||
// 收信人信息
|
||||
var users []*models.User
|
||||
|
||||
// 如果是收信
|
||||
if emailType == 0 {
|
||||
// 找到收信人id
|
||||
accounts := []string{}
|
||||
// 优先取smtp协议中的收件人地址
|
||||
if len(reallyTo) > 0 {
|
||||
for _, s := range reallyTo {
|
||||
account := parsemail.BuilderUser(s)
|
||||
if account != nil {
|
||||
acc, domain := account.GetDomainAccount()
|
||||
if array.InArray(domain, config.Instance.Domains) && acc != "" {
|
||||
accounts = append(accounts, strings.ToLower(acc))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, user := range append(append(email.To, email.Cc...), email.Bcc...) {
|
||||
account, _ := user.GetDomainAccount()
|
||||
if account != "" {
|
||||
accounts = append(accounts, strings.ToLower(account))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 这里会导致索引失效,可以尝试对lower结果加索引
|
||||
PostgreSQL: CREATE INDEX idx_user_account_lower ON "user" (LOWER(account));
|
||||
MySQL8+: ALTER TABLE user ADD INDEX ((LOWER(account)));
|
||||
*/
|
||||
where, params, _ := ToSQL(In("LOWER(account)", accounts))
|
||||
|
||||
err = db.Instance.Table(&models.User{}).Where(where, params...).Find(&users)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("db Select error:%+v", err.Error())
|
||||
}
|
||||
|
||||
if len(users) > 0 {
|
||||
for _, user := range users {
|
||||
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: user.ID, Status: cast.ToInt8(email.Status)}
|
||||
_, err = db.Instance.Insert(&ue)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 找不到收件人
|
||||
// 如果启用了保护功能,且 验证失败,则丢弃邮件
|
||||
// 验证通过的邮件即使收件人不存在也应交给管理员,避免误丢弃合法邮件
|
||||
|
||||
// 垃圾过滤
|
||||
if DropUnknownRecipientEmails &&
|
||||
((config.Instance.SpamFilterLevel == 1 && !SPFStatus && !dkimStatus) ||
|
||||
(config.Instance.SpamFilterLevel == 2 && !SPFStatus) ||
|
||||
(config.Instance.SpamFilterLevel == 3 && !dkimStatus)) {
|
||||
log.WithContext(ctx).Infoln("垃圾邮件,拒信")
|
||||
// 直接删除已插入的邮件记录,不关联任何用户
|
||||
log.WithContext(ctx).Infof("收件人不存在且DKIM验证失败,丢弃邮件: %s -> %v", email.From.EmailAddress, accounts)
|
||||
_, delErr := db.Instance.Delete(&models.Email{Id: modelEmail.Id})
|
||||
if delErr != nil {
|
||||
log.WithContext(ctx).Errorf("db delete error:%+v", delErr.Error())
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// DKIM 验证通过或未启用保护功能时,邮件丢给管理员账号
|
||||
log.WithContext(ctx).Infof("收件人不存在但DKIM验证通过,转交管理员: %s -> %v", email.From.EmailAddress, accounts)
|
||||
|
||||
err = db.Instance.Table(&models.User{}).Where("is_admin=1").Find(&users)
|
||||
for _, user := range users {
|
||||
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: user.ID, Status: cast.ToInt8(email.Status)}
|
||||
_, err = db.Instance.Insert(&ue)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
|
||||
}
|
||||
}
|
||||
var userIDs []int
|
||||
if emailType == int(consts.EmailTypeReceive) {
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
} else {
|
||||
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: ctx.UserID}
|
||||
_, err = db.Instance.Insert(&ue)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
|
||||
}
|
||||
modelEmail.Status = consts.EmailStatusDeliveryPending
|
||||
modelEmail.Error = sql.NullString{String: deliveryPendingError, Valid: true}
|
||||
userIDs = []int{sendUserID}
|
||||
}
|
||||
|
||||
return users, &modelEmail, nil
|
||||
}
|
||||
|
||||
func json2string(d any) string {
|
||||
by, _ := json.Marshal(d)
|
||||
return string(by)
|
||||
log.WithContext(ctx).Debugf("开始入库!")
|
||||
saved, err := persistInitialAudit(ctx, modelEmail, userIDs)
|
||||
if err != nil {
|
||||
return users, nil, err
|
||||
}
|
||||
email.MessageId = cast.ToInt64(saved.Id)
|
||||
email.MsgID = saved.MsgID
|
||||
return users, saved, nil
|
||||
}
|
||||
|
||||
func spfCheck(remoteAddress string, sender *parsemail.User, senderString string) bool {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user