Files
new-api/controller/email_delivery_test.go
邹云森森andCaIon 32e587406c feat(email): 添加 resend 邮件服务商接口 (#7880)
* feat(email): add Resend delivery provider

Add Resend as an alternative to SMTP with separately editable sender address
and name, credential-safe configuration status, and validation before activation.
Preserve the target repository's email notification and balance reminder settings.
Only Resend changes are transplanted from hicoderai/new-api; no landing,
legal-document, model-group, pricing, branding, or dependency changes are included.

Validation: Go common/controller email regression tests; 19 frontend email and
notification tests; frontend production build; tsgo typecheck; touched-file lint.
Frontend validation uses the target lockfile's Axios 1.18.1 with local cached tools.

Security references: OWASP Authentication, Session Management, and Forgot Password
Cheat Sheets. Tests cover credential non-disclosure, invalid activation, sanitized
transport errors, and no automatic retry or fallback; token and session flows
are unchanged.
https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html

* fix(email): log Resend rejections and translate Resend errors

Resend's reason for a rejected or failed send now goes to the server log.
People who asked for the email still see the short error.
Resend and email method errors are translatable messages in all seven
languages.
The email settings hints are shorter and reuse the existing key hint.
The new frontend files carry the license header, the sender name check
passes lint, and test fixtures use neutral names.

* fix(email): check Resend settings while Resend is active

Saving the sender address or API key now checks the whole Resend setup
while Resend sends the mail. A typo the form accepts, such as
notify..team@example.com, is rejected instead of breaking every send.
Resend's error text and network errors are quoted in the server log.

---------

Co-authored-by: CaIon <i@caion.me>
2026-10-19 20:22:37 +08:00

252 lines
12 KiB
Go

package controller
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type emailHTTPTransport func(*http.Request) (*http.Response, error)
func (transport emailHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
func configureEmailDelivery(t *testing.T, options map[string]string) {
t.Helper()
common.OptionMapRWMutex.Lock()
original := common.OptionMap
common.OptionMap = options
common.OptionMapRWMutex.Unlock()
originalTransport := http.DefaultTransport
originalFrom, originalAccount, originalServer := common.SMTPFrom, common.SMTPAccount, common.SMTPServer
common.SMTPFrom, common.SMTPAccount, common.SMTPServer = "", "", ""
t.Cleanup(func() {
common.OptionMapRWMutex.Lock()
common.OptionMap = original
common.OptionMapRWMutex.Unlock()
http.DefaultTransport = originalTransport
common.SMTPFrom, common.SMTPAccount, common.SMTPServer = originalFrom, originalAccount, originalServer
})
}
func TestResendDeliversThroughSelectedProvider(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "New API <sender@example.com>"})
calls := 0
http.DefaultTransport = emailHTTPTransport(func(request *http.Request) (*http.Response, error) {
calls++
assert.Equal(t, "https://api.resend.com/emails", request.URL.String())
assert.Equal(t, http.MethodPost, request.Method)
assert.Equal(t, "Bearer test-key", request.Header.Get("Authorization"))
assert.Equal(t, "application/json", request.Header.Get("Content-Type"))
_, hasDeadline := request.Context().Deadline()
assert.True(t, hasDeadline, "email delivery must have a bounded wait")
var body struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
}
require.NoError(t, common.DecodeJson(request.Body, &body))
assert.Equal(t, `"New API" <sender@example.com>`, body.From)
assert.Equal(t, []string{"one@example.com", "two@example.com"}, body.To)
assert.Equal(t, "验证码", body.Subject)
assert.Equal(t, "<p>123456</p>", body.HTML)
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"email-test-id"}`)), Header: make(http.Header)}, nil
})
require.NoError(t, common.SendEmail("验证码", "one@example.com; two@example.com", "<p>123456</p>"))
assert.Equal(t, 1, calls, "no SMTP configuration is needed and no duplicate request is made")
}
func TestResendFailuresDoNotRetryFallbackOrExposeSensitiveContent(t *testing.T) {
for _, status := range []int{302, 400, 401, 403, 429, 500} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
calls := 0
http.DefaultTransport = emailHTTPTransport(func(request *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: status, Header: http.Header{"Location": []string{"https://untrusted.example/emails"}}, Body: io.NopCloser(strings.NewReader(`{"message":"test-key 123456 reset-token"}`))}, nil
})
err := common.SendEmail("verification", "one@example.com", "123456 reset-token")
require.EqualError(t, err, "Resend email delivery failed (HTTP "+strconv.Itoa(status)+")")
assert.Equal(t, 1, calls, "do not retry or follow redirects")
})
}
}
func TestResendRejectsIncompleteOrInvalidConfigurationBeforeSending(t *testing.T) {
tests := []struct{ name, key, from, receiver string }{
{"missing key", "", "sender@example.com", "one@example.com"},
{"header injection", "key\r\nInjected: value", "sender@example.com", "one@example.com"},
{"missing sender", "test-key", "", "one@example.com"},
{"invalid sender", "test-key", "bad-address", "one@example.com"},
{"invalid recipient", "test-key", "sender@example.com", "bad-address"},
{"empty recipient", "test-key", "sender@example.com", ""},
{"too many recipients", "test-key", "sender@example.com", strings.Repeat("one@example.com;", 50) + "two@example.com"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": test.key, "ResendFrom": test.from})
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) {
t.Fatal("invalid settings must not make a request")
return nil, errors.New("unexpected request")
})
err := common.SendEmail("verification", test.receiver, "123456")
require.Error(t, err)
assert.NotContains(t, err.Error(), "123456")
assert.NotContains(t, err.Error(), "test-key")
})
}
}
func TestResendRejectsMalformedSuccessAndSanitizesTransportErrors(t *testing.T) {
for _, body := range []string{`{}`, `not json`, `{"id":false}`, `{"id":""}`} {
t.Run(body, func(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
require.EqualError(t, common.SendEmail("verification", "one@example.com", "123456"), "Invalid Resend delivery response")
})
}
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) { return nil, errors.New("test-key 123456 reset-token") })
require.EqualError(t, common.SendEmail("verification", "one@example.com", "123456"), "Resend request failed")
}
func TestSMTPAndLegacyConfigurationNeverUseResend(t *testing.T) {
for _, provider := range []string{"", "smtp", "invalid"} {
t.Run("provider="+provider, func(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": provider, "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) {
t.Fatal("SMTP and unknown providers must not invoke Resend")
return nil, errors.New("unexpected request")
})
require.Error(t, common.SendEmail("verification", "one@example.com", "123456"))
})
}
}
func TestEmailOptionsNeverReturnResendCredentials(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "private-email-credential", "ResendFrom": "sender@example.com"})
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodGet, "/api/option/", nil)
GetOptions(context)
require.Equal(t, http.StatusOK, response.Code)
assert.NotContains(t, response.Body.String(), "private-email-credential")
assert.NotContains(t, response.Body.String(), "ResendApiKey")
assert.Contains(t, response.Body.String(), "ResendFrom")
assert.Contains(t, response.Body.String(), `"key":"ResendKeyConfigured","value":"true"`)
}
func TestEmailProviderOptionRejectsInvalidValues(t *testing.T) {
for _, provider := range []string{"", "both", "SMTP", "smtp,resend"} {
t.Run(provider, func(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "smtp"})
body, err := common.Marshal(OptionUpdateRequest{Key: "EmailProvider", Value: provider})
require.NoError(t, err)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
UpdateOption(context)
assert.Contains(t, response.Body.String(), `"success":false`)
assert.Contains(t, response.Body.String(), `"message_key":"Email sending method must be SMTP or Resend"`)
assert.Equal(t, "smtp", common.OptionMap["EmailProvider"])
})
}
}
func TestEmailProviderCannotActivateIncompleteResend(t *testing.T) {
for name, settings := range map[string]map[string]string{
"missing key": {"ResendFrom": "sender@example.com"},
"missing sender": {"ResendApiKey": "test-key"},
"invalid sender": {"ResendApiKey": "test-key", "ResendFrom": "invalid"},
"invalid key": {"ResendApiKey": "key\r\nInjected: value", "ResendFrom": "sender@example.com"},
} {
t.Run(name, func(t *testing.T) {
settings["EmailProvider"] = "smtp"
configureEmailDelivery(t, settings)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(`{"key":"EmailProvider","value":"resend"}`))
require.NotPanics(t, func() { UpdateOption(context) }, "invalid activation must fail before accessing storage")
assert.Contains(t, response.Body.String(), `"success":false`)
assert.Contains(t, response.Body.String(), `"message_key":"Resend `)
assert.Equal(t, "smtp", common.OptionMap["EmailProvider"])
assert.NotContains(t, response.Body.String(), "test-key")
})
}
}
func TestResendSettingsChangesKeepActiveDeliveryWorking(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Option{}))
previousDB, previousRedis := model.DB, common.RedisEnabled
model.DB, common.RedisEnabled = db, false
t.Cleanup(func() { model.DB, common.RedisEnabled = previousDB, previousRedis })
tests := []struct {
name, provider, key, value string
saved bool
}{
{"active sender the parser rejects", "resend", "ResendFrom", "notify..team@example.com", false},
{"active key cleared", "resend", "ResendApiKey", " ", false},
{"active sender renamed", "resend", "ResendFrom", `"New API" <notify@example.com>`, true},
{"inactive sender saved before its key", "smtp", "ResendFrom", "notify@example.com", true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
settings := map[string]string{"EmailProvider": test.provider, "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"}
if test.provider == "smtp" {
settings["ResendApiKey"] = ""
}
configureEmailDelivery(t, settings)
before := settings[test.key]
body, err := common.Marshal(OptionUpdateRequest{Key: test.key, Value: test.value})
require.NoError(t, err)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
UpdateOption(context)
if test.saved {
assert.Contains(t, response.Body.String(), `"success":true`)
assert.Equal(t, test.value, common.OptionMap[test.key])
return
}
assert.Contains(t, response.Body.String(), `"message_key":"Resend `)
assert.Equal(t, before, common.OptionMap[test.key])
})
}
}
func TestEmailKeyStatusIsDerivedAndReadOnly(t *testing.T) {
configureEmailDelivery(t, map[string]string{"EmailProvider": "smtp", "ResendKeyConfigured": "true"})
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodGet, "/api/option/", nil)
GetOptions(context)
assert.Contains(t, response.Body.String(), `"key":"ResendKeyConfigured","value":"false"`)
assert.NotContains(t, response.Body.String(), `"value":"true"`)
response = httptest.NewRecorder()
context, _ = gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(`{"key":"ResendKeyConfigured","value":true}`))
UpdateOption(context)
assert.Contains(t, response.Body.String(), `"success":false`)
assert.Contains(t, response.Body.String(), "read-only")
}