mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
* 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 [email protected], is rejected instead of breaking every send. Resend's error text and network errors are quoted in the server log. --------- Co-authored-by: CaIon <[email protected]>
252 lines
12 KiB
Go
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 <[email protected]>"})
|
|
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" <[email protected]>`, body.From)
|
|
assert.Equal(t, []string{"[email protected]", "[email protected]"}, 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("验证码", "[email protected]; [email protected]", "<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": "[email protected]"})
|
|
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", "[email protected]", "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", "", "[email protected]", "[email protected]"},
|
|
{"header injection", "key\r\nInjected: value", "[email protected]", "[email protected]"},
|
|
{"missing sender", "test-key", "", "[email protected]"},
|
|
{"invalid sender", "test-key", "bad-address", "[email protected]"},
|
|
{"invalid recipient", "test-key", "[email protected]", "bad-address"},
|
|
{"empty recipient", "test-key", "[email protected]", ""},
|
|
{"too many recipients", "test-key", "[email protected]", strings.Repeat("[email protected];", 50) + "[email protected]"},
|
|
}
|
|
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": "[email protected]"})
|
|
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", "[email protected]", "123456"), "Invalid Resend delivery response")
|
|
})
|
|
}
|
|
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "[email protected]"})
|
|
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) { return nil, errors.New("test-key 123456 reset-token") })
|
|
require.EqualError(t, common.SendEmail("verification", "[email protected]", "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": "[email protected]"})
|
|
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", "[email protected]", "123456"))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmailOptionsNeverReturnResendCredentials(t *testing.T) {
|
|
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "private-email-credential", "ResendFrom": "[email protected]"})
|
|
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": "[email protected]"},
|
|
"missing sender": {"ResendApiKey": "test-key"},
|
|
"invalid sender": {"ResendApiKey": "test-key", "ResendFrom": "invalid"},
|
|
"invalid key": {"ResendApiKey": "key\r\nInjected: value", "ResendFrom": "[email protected]"},
|
|
} {
|
|
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", "[email protected]", false},
|
|
{"active key cleared", "resend", "ResendApiKey", " ", false},
|
|
{"active sender renamed", "resend", "ResendFrom", `"New API" <[email protected]>`, true},
|
|
{"inactive sender saved before its key", "smtp", "ResendFrom", "[email protected]", true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
settings := map[string]string{"EmailProvider": test.provider, "ResendApiKey": "test-key", "ResendFrom": "[email protected]"}
|
|
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")
|
|
}
|