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>
This commit is contained in:
邹云森森andCaIon authored and GitHub committed 2026-10-19 20:22:37 +08:00
1 parent 25372e30b1
commit 32e587406c
20 files changed
+1394 -281

No files matched your search

+14
View File
@@ -76,6 +76,20 @@ func newSMTPClient(addr string) (*smtp.Client, error) {
}
func SendEmail(subject string, receiver string, content string) error {
OptionMapRWMutex.RLock()
provider := OptionMap["EmailProvider"]
resendKey := OptionMap["ResendApiKey"]
resendFrom := OptionMap["ResendFrom"]
OptionMapRWMutex.RUnlock()
// One selector controls delivery. Never fall back to a second provider:
// a failed response does not prove that the first provider did not send.
switch provider {
case "resend":
return sendResendEmail(resendKey, resendFrom, subject, receiver, content)
case "", "smtp": // Preserve existing installations without a selector.
default:
return NewMessage("Email sending method must be SMTP or Resend")
}
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
}
+98
View File
@@ -0,0 +1,98 @@
package common
import (
"bytes"
"context"
"io"
"net/http"
"net/mail"
"strings"
"time"
)
var resendEmailClient = &http.Client{
Timeout: 20 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
// ValidateResendConfiguration is shared by provider activation and delivery.
// It checks local configuration only; saving settings never sends an email.
func ValidateResendConfiguration(apiKey, from string) error {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" || strings.ContainsAny(apiKey, "\r\n\x00") {
return NewMessage("Resend API key is not configured or invalid")
}
if strings.ContainsAny(from, "\r\n\x00") {
return NewMessage("Resend sender address is not configured or invalid")
}
if _, err := mail.ParseAddress(strings.TrimSpace(from)); err != nil {
return NewMessage("Resend sender address is not configured or invalid")
}
return nil
}
// API contract: https://resend.com/docs/api-reference/emails/send-email
// OWASP Authentication / Session Management / Forgot Password cheat sheets:
// do not expose credentials, verification codes or reset links in errors/logs.
// This transport does not change token expiry, single-use or session rules.
func sendResendEmail(apiKey, from, subject, receiver, content string) error {
if err := ValidateResendConfiguration(apiKey, from); err != nil {
return err
}
apiKey = strings.TrimSpace(apiKey)
sender, _ := mail.ParseAddress(strings.TrimSpace(from))
if sender.Name == "" {
sender.Name = SystemName
}
to := make([]string, 0)
for address := range strings.SplitSeq(receiver, ";") {
recipient, err := mail.ParseAddress(strings.TrimSpace(address))
if err != nil || len(to) >= 50 {
return NewMessage("Invalid email address")
}
to = append(to, recipient.Address)
}
payload, err := Marshal(struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
}{sender.String(), to, subject, content})
if err != nil {
return NewMessage("Resend request failed")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(payload))
if err != nil {
return NewMessage("Resend request failed")
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := resendEmailClient.Do(req)
if err != nil {
SysError(LogText("Resend request failed: error=%q", err))
return NewMessage("Resend request failed")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The reason (unverified domain, invalid key, quota) goes to the server
// log only, because the error may reach whoever asked for the email.
var failure struct {
Name string `json:"name"`
Message string `json:"message"`
}
_ = DecodeJson(io.LimitReader(resp.Body, 4096), &failure)
SysError(LogText("Resend rejected the email: status=%d name=%q message=%q", resp.StatusCode, failure.Name, failure.Message))
return NewMessage("Resend email delivery failed (HTTP {{status}})", map[string]any{"status": resp.StatusCode})
}
var result struct {
ID string `json:"id"`
}
if err := DecodeJson(io.LimitReader(resp.Body, 4096), &result); err != nil || strings.TrimSpace(result.ID) == "" {
return NewMessage("Invalid Resend delivery response")
}
return nil
}
+2
View File
@@ -148,6 +148,8 @@
"RemoveGeminiDisabledFields Marshal error: %s": "RemoveGeminiDisabledFields 序列化失败: %s",
"RemoveGeminiDisabledFields Unmarshal error: %s": "RemoveGeminiDisabledFields 反序列化失败: %s",
"Rerank request body: %s": "重排序请求体: %s",
"Resend rejected the email: status=%d name=%q message=%q": "Resend 拒绝发送邮件: status=%d name=%q message=%q",
"Resend request failed: error=%q": "请求 Resend 失败: error=%q",
"SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check": "已设置 SKIP_64BIT_QUOTA_SCHEMA_CHECK=true,跳过用户额度字段的类型检查",
"SQL_DSN not set, using SQLite as database": "未设置 SQL_DSN,数据库使用 SQLite",
"SSE ping data sent": "SSE ping 数据已发送",
+251
View File
@@ -0,0 +1,251 @@
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")
}
+4 -1
View File
@@ -85,7 +85,7 @@ func GetOptions(c *gin.Context) {
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" {
if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" || k == "ResendKeyConfigured" {
continue
}
value := common.Interface2String(v)
@@ -105,6 +105,9 @@ func GetOptions(c *gin.Context) {
optionValues[k] = value
}
}
options = append(options, &model.Option{
Key: "ResendKeyConfigured", Value: strconv.FormatBool(strings.TrimSpace(common.OptionMap["ResendApiKey"]) != ""),
})
common.OptionMapRWMutex.Unlock()
// Display the same effective expressions used by pricing and settlement,
// including built-in defaults absent from persisted administrator options.
+24
View File
@@ -66,6 +66,9 @@ func InitOptionMap() {
common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled)
common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",")
common.OptionMap["SMTPServer"] = ""
common.OptionMap["EmailProvider"] = "smtp"
common.OptionMap["ResendApiKey"] = ""
common.OptionMap["ResendFrom"] = ""
common.OptionMap["SMTPFrom"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
common.OptionMap["SMTPAccount"] = ""
@@ -235,6 +238,27 @@ func validateOptionValue(key string, value string) error {
if err := operation_setting.ValidateQuotaOption(key, value); err != nil {
return err
}
if key == "EmailProvider" || key == "ResendApiKey" || key == "ResendFrom" {
if key == "EmailProvider" && value != "smtp" && value != "resend" {
return common.NewMessage("Email sending method must be SMTP or Resend")
}
common.OptionMapRWMutex.RLock()
settings := map[string]string{
"EmailProvider": common.OptionMap["EmailProvider"],
"ResendApiKey": common.OptionMap["ResendApiKey"],
"ResendFrom": common.OptionMap["ResendFrom"],
}
common.OptionMapRWMutex.RUnlock()
settings[key] = value
// While Resend is the active method, a change must keep settings it can
// send with. Inactive drafts are checked when Resend is turned on.
if settings["EmailProvider"] == "resend" {
return common.ValidateResendConfiguration(settings["ResendApiKey"], settings["ResendFrom"])
}
}
if key == "ResendKeyConfigured" {
return common.NewMessage("Resend key configuration status is read-only")
}
if key == operation_setting.ToolPriceOptionKey {
return operation_setting.ValidateToolPricesJSON(value)
}
@@ -35,6 +35,10 @@ function Fixture() {
<SettingsPageProvider actionsContainer={container}>
<EmailSettingsSection
defaultValues={{
EmailProvider: 'smtp',
ResendApiKey: '',
ResendKeyConfigured: false,
ResendFrom: '',
SMTPServer: 'smtp.example.com',
SMTPPort: '587',
SMTPAccount: 'noreply@example.com',
@@ -0,0 +1,344 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState, type ComponentProps } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { SettingsPageProvider } from '../../components/settings-page-context'
import { createEmailSchema } from '../email-settings-schema'
import { EmailSettingsSection } from '../email-settings-section'
type EmailValues = ComponentProps<typeof EmailSettingsSection>['defaultValues']
const defaults: EmailValues = {
QuotaRemindEnabled: true,
QuotaRemindThreshold: '0',
EmailProvider: 'smtp',
ResendApiKey: '',
ResendKeyConfigured: true,
ResendFrom: 'notify@example.com',
SMTPServer: 'smtp.example.com',
SMTPPort: '587',
SMTPAccount: 'sender@example.com',
SMTPFrom: 'sender@example.com',
SMTPToken: '',
SMTPSSLEnabled: false,
SMTPStartTLSEnabled: true,
SMTPInsecureSkipVerify: false,
SMTPForceAuthLogin: false,
}
function EmailFixture(props: { values: EmailValues }) {
const [actions, setActions] = useState<HTMLDivElement | null>(null)
return (
<SettingsPageProvider actionsContainer={actions}>
<div ref={setActions} />
<EmailSettingsSection defaultValues={props.values} />
</SettingsPageProvider>
)
}
function renderEmail(values: Partial<EmailValues> = {}) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<EmailFixture values={{ ...defaults, ...values }} />
</QueryClientProvider>
)
return userEvent.setup()
}
afterEach(() => vi.restoreAllMocks())
describe('email delivery settings', () => {
it('keeps balance notifications editable when Resend is selected', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({ EmailProvider: 'resend' })
expect(
screen.getByRole('textbox', { name: 'Sender email address' })
).toBeVisible()
await user.click(
screen.getByRole('switch', { name: 'Low balance reminder' })
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(request).toHaveBeenCalledWith('/api/option/', {
key: 'QuotaRemindEnabled',
value: false,
})
)
expect(request).toHaveBeenCalledTimes(1)
})
it('requires a key before first activating Resend and does not disable SMTP', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({ ResendKeyConfigured: false })
await user.click(screen.getByRole('radio', { name: 'Resend API' }))
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
expect(
await screen.findByText(
'Configure a Resend API key before enabling Resend'
)
).toBeVisible()
expect(request).not.toHaveBeenCalled()
})
it('selects only one provider, hides inactive fields and retains SMTP drafts when switching back', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail()
const host = screen.getByRole('textbox', { name: 'SMTP Host' })
await user.clear(host)
await user.type(host, 'draft.smtp.example.com')
await user.click(screen.getByRole('radio', { name: 'Resend API' }))
expect(screen.getByRole('radio', { name: 'Resend API' })).toBeChecked()
expect(screen.getByRole('radio', { name: 'SMTP' })).not.toBeChecked()
expect(
screen.queryByRole('textbox', { name: 'SMTP Host' })
).not.toBeInTheDocument()
expect(screen.getByLabelText('Resend API key')).toHaveAttribute(
'type',
'password'
)
await user.click(screen.getByRole('radio', { name: 'SMTP' }))
expect(screen.getByRole('textbox', { name: 'SMTP Host' })).toHaveValue(
'draft.smtp.example.com'
)
expect(screen.queryByLabelText('Resend API key')).not.toBeInTheDocument()
expect(request).not.toHaveBeenCalled()
})
it('saves Resend credentials before activating it and ignores invalid inactive SMTP fields', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({
ResendKeyConfigured: false,
SMTPPort: 'invalid',
SMTPFrom: 'invalid',
})
await user.click(screen.getByRole('radio', { name: 'Resend API' }))
const from = screen.getByRole('textbox', { name: 'Sender email address' })
await user.clear(from)
await user.type(from, 'mail@example.com')
await user.type(
screen.getByRole('textbox', { name: 'Sender name' }),
'New API'
)
await user.type(screen.getByLabelText('Resend API key'), 'test-key')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(request).toHaveBeenCalledTimes(3))
expect(request.mock.calls.map((call) => call[1])).toEqual([
{ key: 'ResendFrom', value: '"New API" <mail@example.com>' },
{ key: 'ResendApiKey', value: 'test-key' },
{ key: 'EmailProvider', value: 'resend' },
])
await waitFor(() =>
expect(screen.getByLabelText('Resend API key')).toHaveValue('')
)
})
it('keeps a saved Resend key when its input is blank', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({ EmailProvider: 'resend' })
const from = screen.getByRole('textbox', { name: 'Sender email address' })
await user.clear(from)
await user.type(from, 'changed@example.com')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
expect(request).toHaveBeenCalledWith('/api/option/', {
key: 'ResendFrom',
value: 'changed@example.com',
})
})
it('retains both Resend sender drafts when switching providers', async () => {
const user = renderEmail({ EmailProvider: 'resend' })
const from = screen.getByRole('textbox', { name: 'Sender email address' })
await user.clear(from)
await user.type(from, 'notify@another.example')
await user.type(
screen.getByRole('textbox', { name: 'Sender name' }),
'New API 通知'
)
await user.click(screen.getByRole('radio', { name: 'SMTP' }))
expect(
screen.queryByRole('textbox', { name: 'Sender name' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: 'Resend API' }))
expect(
screen.getByRole('textbox', { name: 'Sender email address' })
).toHaveValue('notify@another.example')
expect(screen.getByRole('textbox', { name: 'Sender name' })).toHaveValue(
'New API 通知'
)
})
it('switches back to SMTP without clearing or updating Resend credentials', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({ EmailProvider: 'resend' })
await user.click(screen.getByRole('radio', { name: 'SMTP' }))
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
expect(request).toHaveBeenCalledWith('/api/option/', {
key: 'EmailProvider',
value: 'smtp',
})
})
it('does not activate Resend if saving its credential fails', async () => {
const request = vi.spyOn(api, 'put').mockResolvedValue({
data: { success: false, message: 'Credential save rejected' },
})
const user = renderEmail()
await user.click(screen.getByRole('radio', { name: 'Resend API' }))
await user.type(screen.getByLabelText('Resend API key'), 'test-key')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Save Changes' })).toBeEnabled()
)
expect(request.mock.calls.map((call) => call[1])).toEqual([
{ key: 'ResendApiKey', value: 'test-key' },
])
})
it('rejects an empty Resend sender without submitting settings', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({ EmailProvider: 'resend', ResendFrom: '' })
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
expect(
await screen.findByText('Enter a valid sender email address')
).toBeVisible()
expect(request).not.toHaveBeenCalled()
})
it('supports changing the provider with the keyboard', async () => {
const user = renderEmail()
await user.click(screen.getByRole('radio', { name: 'SMTP' }))
await user.keyboard('{ArrowRight}')
expect(screen.getByRole('radio', { name: 'Resend API' })).toBeChecked()
expect(
screen.getByRole('textbox', { name: 'Sender email address' })
).toBeVisible()
})
it('loads a legacy named sender into two fields without changing saved settings', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({
EmailProvider: 'resend',
ResendFrom: 'New API <notify@example.com>',
})
expect(
screen.getByRole('textbox', { name: 'Sender email address' })
).toHaveValue('notify@example.com')
expect(screen.getByRole('textbox', { name: 'Sender name' })).toHaveValue(
'New API'
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
expect(request).not.toHaveBeenCalled()
})
it('round-trips quoted sender names and allows clearing the name', async () => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({
EmailProvider: 'resend',
ResendFrom: '"New API, \\"Mail\\"" <notify@example.com>',
})
const name = screen.getByRole('textbox', { name: 'Sender name' })
expect(name).toHaveValue('New API, "Mail"')
await user.clear(name)
await user.type(name, 'New API, "Support"')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(request).toHaveBeenLastCalledWith('/api/option/', {
key: 'ResendFrom',
value: '"New API, \\"Support\\"" <notify@example.com>',
})
)
await user.clear(name)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(request).toHaveBeenLastCalledWith('/api/option/', {
key: 'ResendFrom',
value: 'notify@example.com',
})
)
})
it.each(['notify', 'New API <notify@example.com>'])(
'rejects %s instead of a complete standalone sender email',
async (address) => {
const request = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = renderEmail({ EmailProvider: 'resend' })
const from = screen.getByRole('textbox', { name: 'Sender email address' })
await user.clear(from)
await user.type(from, address)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
expect(
await screen.findByText('Enter a valid sender email address')
).toBeVisible()
expect(request).not.toHaveBeenCalled()
}
)
it('validates provider values on the form boundary', () => {
expect(
createEmailSchema((key) => key).safeParse({
...defaults,
ResendSenderName: '',
EmailProvider: 'both',
}).success
).toBe(false)
})
it('rejects line breaks in a Resend sender name', () => {
const parsed = createEmailSchema((key) => key).safeParse({
...defaults,
EmailProvider: 'resend',
ResendSenderName: 'New API\r\nBcc: other@example.com',
})
expect(parsed.success).toBe(false)
if (!parsed.success) {
expect(parsed.error.issues).toEqual([
expect.objectContaining({ path: ['ResendSenderName'] }),
])
}
})
})
@@ -0,0 +1,107 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as z from 'zod'
export const createEmailSchema = (t: (key: string) => string) =>
z
.object({
EmailProvider: z.enum(['smtp', 'resend']),
ResendApiKey: z.string(),
ResendKeyConfigured: z.boolean(),
ResendFrom: z.string(),
ResendSenderName: z.string(),
SMTPServer: z.string(),
SMTPPort: z.string(),
SMTPAccount: z.string(),
SMTPFrom: z.string(),
SMTPToken: z.string(),
SMTPSSLEnabled: z.boolean(),
SMTPStartTLSEnabled: z.boolean(),
SMTPInsecureSkipVerify: z.boolean(),
SMTPForceAuthLogin: z.boolean(),
QuotaRemindEnabled: z.boolean(),
// Entered as a display amount; stored in quota units.
QuotaRemindThreshold: z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return Number.isFinite(Number(trimmed)) && Number(trimmed) >= 0
}, t('Must be greater than or equal to 0')),
})
.superRefine((values, context) => {
if (values.EmailProvider === 'resend') {
if (!values.ResendKeyConfigured && !values.ResendApiKey.trim()) {
context.addIssue({
code: 'custom',
path: ['ResendApiKey'],
message: t('Configure a Resend API key before enabling Resend'),
})
}
const from = values.ResendFrom.trim()
if (!/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(from)) {
context.addIssue({
code: 'custom',
path: ['ResendFrom'],
message: t('Enter a valid sender email address'),
})
}
const name = values.ResendSenderName
if (/[\r\n]/.test(name) || name.includes('\0')) {
context.addIssue({
code: 'custom',
path: ['ResendSenderName'],
message: t(
'Sender name must not contain line breaks or null characters'
),
})
}
return
}
const port = values.SMTPPort.trim()
if (
port &&
(!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)
) {
context.addIssue({
code: 'custom',
path: ['SMTPPort'],
message: t('Port must be a positive integer'),
})
}
const from = values.SMTPFrom.trim()
if (from && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(from)) {
context.addIssue({
code: 'custom',
path: ['SMTPFrom'],
message: t('Enter a valid email or leave blank'),
})
}
})
export type EmailFormValues = z.infer<ReturnType<typeof createEmailSchema>>
// Keep the existing ResendFrom option/API contract while editing its two parts.
export function parseResendSender(value: string) {
const named = value.trim().match(/^(.*?)\s*<([^<>]+)>$/)
if (!named) return { ResendFrom: value.trim(), ResendSenderName: '' }
let name = named[1].trim()
if (name.startsWith('"') && name.endsWith('"')) {
name = name.slice(1, -1).replaceAll(/\\(.)/g, '$1')
}
return { ResendFrom: named[2].trim(), ResendSenderName: name }
}
@@ -18,10 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import { PasswordInput } from '@/components/password-input'
import {
Form,
FormControl,
@@ -37,6 +37,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Switch } from '@/components/ui/switch'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import {
SettingsControlChildren,
@@ -49,40 +50,14 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
const createEmailSchema = (t: (key: string) => string) =>
z.object({
SMTPServer: z.string(),
SMTPPort: z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return /^\d+$/.test(trimmed)
}, t('Port must be a positive integer')),
SMTPAccount: z.string(),
SMTPFrom: z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
}, t('Enter a valid email or leave blank')),
SMTPToken: z.string(),
SMTPSSLEnabled: z.boolean(),
SMTPStartTLSEnabled: z.boolean(),
SMTPInsecureSkipVerify: z.boolean(),
SMTPForceAuthLogin: z.boolean(),
QuotaRemindEnabled: z.boolean(),
// Entered as a display amount; stored in quota units.
QuotaRemindThreshold: z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return Number.isFinite(Number(trimmed)) && Number(trimmed) >= 0
}, t('Must be greater than or equal to 0')),
})
type EmailFormValues = z.infer<ReturnType<typeof createEmailSchema>>
import {
createEmailSchema,
parseResendSender,
type EmailFormValues,
} from './email-settings-schema'
type EmailSettingsSectionProps = {
/** Option values as stored; QuotaRemindThreshold is in quota units */
defaultValues: EmailFormValues
defaultValues: Omit<EmailFormValues, 'ResendSenderName'>
}
type SmtpSecurityMode = 'none' | 'ssl_tls' | 'starttls'
@@ -102,12 +77,10 @@ export function EmailSettingsSection({
const { t } = useTranslation()
const updateOption = useUpdateOption()
const emailSchema = createEmailSchema(t)
// The form edits the threshold as a display amount. Comparing amounts on
// save leaves a threshold nobody edited untouched.
const formDefaults = useMemo(
const initialValues = useMemo(
() => ({
...defaultValues,
...parseResendSender(defaultValues.ResendFrom),
QuotaRemindThreshold: String(
quotaUnitsToDollars(Number(defaultValues.QuotaRemindThreshold) || 0)
),
@@ -117,13 +90,21 @@ export function EmailSettingsSection({
const form = useForm<EmailFormValues>({
resolver: zodResolver(emailSchema),
defaultValues: formDefaults,
defaultValues: initialValues,
})
useResetForm(form, formDefaults)
const quotaRemindEnabled = form.watch('QuotaRemindEnabled')
const [provider, smtpSSL, smtpStartTLS, quotaRemindEnabled] = useWatch({
control: form.control,
name: [
'EmailProvider',
'SMTPSSLEnabled',
'SMTPStartTLSEnabled',
'QuotaRemindEnabled',
],
})
const tokensOnly = getCurrencyDisplay().meta.kind === 'tokens'
const isSaving = form.formState.isSubmitting || updateOption.isPending
useResetForm(form, isSaving ? undefined : initialValues)
const onSubmit = async (values: EmailFormValues) => {
const securityMode = getSmtpSecurityMode(values)
@@ -153,52 +134,69 @@ export function EmailSettingsSection({
const updates: Array<{ key: string; value: string | boolean }> = []
if (sanitized.SMTPServer !== initial.SMTPServer) {
updates.push({ key: 'SMTPServer', value: sanitized.SMTPServer })
}
if (values.EmailProvider === 'resend') {
const from = values.ResendFrom.trim()
const name = values.ResendSenderName.trim()
if (
from !== initialValues.ResendFrom ||
name !== initialValues.ResendSenderName
) {
// Quote display names so commas, quotes and angle brackets stay text,
// not mailbox syntax. The server also validates the combined address.
const escapedName = name.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
const sender = name ? `"${escapedName}" <${from}>` : from
updates.push({ key: 'ResendFrom', value: sender })
}
const key = values.ResendApiKey.trim()
if (key) updates.push({ key: 'ResendApiKey', value: key })
} else {
if (sanitized.SMTPServer !== initial.SMTPServer) {
updates.push({ key: 'SMTPServer', value: sanitized.SMTPServer })
}
if (sanitized.SMTPPort !== initial.SMTPPort) {
updates.push({ key: 'SMTPPort', value: sanitized.SMTPPort })
}
if (sanitized.SMTPPort !== initial.SMTPPort) {
updates.push({ key: 'SMTPPort', value: sanitized.SMTPPort })
}
if (sanitized.SMTPAccount !== initial.SMTPAccount) {
updates.push({ key: 'SMTPAccount', value: sanitized.SMTPAccount })
}
if (sanitized.SMTPAccount !== initial.SMTPAccount) {
updates.push({ key: 'SMTPAccount', value: sanitized.SMTPAccount })
}
if (sanitized.SMTPFrom !== initial.SMTPFrom) {
updates.push({ key: 'SMTPFrom', value: sanitized.SMTPFrom })
}
if (sanitized.SMTPFrom !== initial.SMTPFrom) {
updates.push({ key: 'SMTPFrom', value: sanitized.SMTPFrom })
}
if (sanitized.SMTPToken && sanitized.SMTPToken !== initial.SMTPToken) {
updates.push({ key: 'SMTPToken', value: sanitized.SMTPToken })
}
if (sanitized.SMTPToken && sanitized.SMTPToken !== initial.SMTPToken) {
updates.push({ key: 'SMTPToken', value: sanitized.SMTPToken })
}
if (sanitized.SMTPSSLEnabled !== initial.SMTPSSLEnabled) {
updates.push({
key: 'SMTPSSLEnabled',
value: sanitized.SMTPSSLEnabled,
})
}
if (sanitized.SMTPSSLEnabled !== initial.SMTPSSLEnabled) {
updates.push({
key: 'SMTPSSLEnabled',
value: sanitized.SMTPSSLEnabled,
})
}
if (sanitized.SMTPStartTLSEnabled !== initial.SMTPStartTLSEnabled) {
updates.push({
key: 'SMTPStartTLSEnabled',
value: sanitized.SMTPStartTLSEnabled,
})
}
if (sanitized.SMTPStartTLSEnabled !== initial.SMTPStartTLSEnabled) {
updates.push({
key: 'SMTPStartTLSEnabled',
value: sanitized.SMTPStartTLSEnabled,
})
}
if (sanitized.SMTPInsecureSkipVerify !== initial.SMTPInsecureSkipVerify) {
updates.push({
key: 'SMTPInsecureSkipVerify',
value: sanitized.SMTPInsecureSkipVerify,
})
}
if (sanitized.SMTPInsecureSkipVerify !== initial.SMTPInsecureSkipVerify) {
updates.push({
key: 'SMTPInsecureSkipVerify',
value: sanitized.SMTPInsecureSkipVerify,
})
}
if (sanitized.SMTPForceAuthLogin !== initial.SMTPForceAuthLogin) {
updates.push({
key: 'SMTPForceAuthLogin',
value: sanitized.SMTPForceAuthLogin,
})
if (sanitized.SMTPForceAuthLogin !== initial.SMTPForceAuthLogin) {
updates.push({
key: 'SMTPForceAuthLogin',
value: sanitized.SMTPForceAuthLogin,
})
}
}
if (values.QuotaRemindEnabled !== defaultValues.QuotaRemindEnabled) {
@@ -209,15 +207,29 @@ export function EmailSettingsSection({
}
const threshold = values.QuotaRemindThreshold.trim()
if (threshold !== formDefaults.QuotaRemindThreshold) {
if (threshold !== initialValues.QuotaRemindThreshold) {
updates.push({
key: 'QuotaRemindThreshold',
value: String(parseQuotaFromDollars(Number(threshold || 0))),
})
}
for (const update of updates) {
await updateOption.mutateAsync(update)
// Save credentials first. A failed configuration write must not activate
// the new provider; only the final selector write changes delivery.
if (values.EmailProvider !== defaultValues.EmailProvider) {
updates.push({ key: 'EmailProvider', value: values.EmailProvider })
}
try {
for (const update of updates) {
await updateOption.mutateAsync(update)
if (update.key === 'ResendApiKey') {
form.setValue('ResendKeyConfigured', true)
}
}
form.setValue('ResendApiKey', '')
} catch (error) {
handleServerError(error)
}
}
@@ -227,227 +239,350 @@ export function EmailSettingsSection({
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
isSaving={isSaving}
/>
<FormField
control={form.control}
name='SMTPServer'
name='EmailProvider'
render={({ field }) => (
<FormItem>
<FormLabel>{t('SMTP Host')}</FormLabel>
<FormItem data-settings-form-span='full'>
<FormLabel>{t('Email sending method')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
placeholder={t('smtp.example.com')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
disabled={isSaving}
className='grid-cols-1 gap-3 sm:grid-cols-2'
>
<Label
className='flex cursor-pointer items-center gap-3 rounded-lg border p-4'
htmlFor='email-provider-smtp'
>
<RadioGroupItem value='smtp' id='email-provider-smtp' />
SMTP
</Label>
<Label
className='flex cursor-pointer items-center gap-3 rounded-lg border p-4'
htmlFor='email-provider-resend'
>
<RadioGroupItem
value='resend'
id='email-provider-resend'
/>
Resend API
</Label>
</RadioGroup>
</FormControl>
<FormDescription>
{t('Hostname or IP of your SMTP provider')}
{t(
'Only the selected method sends email. The other method keeps its settings.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-6 md:grid-cols-2'>
<FormField
control={form.control}
name='SMTPPort'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
type='number'
placeholder='587'
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Common ports include 25, 465, and 587')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('SMTP encryption')}</FormLabel>
<FormControl>
<RadioGroup
value={getSmtpSecurityMode({
SMTPSSLEnabled: form.watch('SMTPSSLEnabled'),
SMTPStartTLSEnabled: form.watch('SMTPStartTLSEnabled'),
})}
onValueChange={(value) => {
const mode = value as SmtpSecurityMode
form.setValue('SMTPSSLEnabled', mode === 'ssl_tls', {
shouldDirty: true,
})
form.setValue('SMTPStartTLSEnabled', mode === 'starttls', {
shouldDirty: true,
})
}}
className='gap-3'
>
<div className='flex items-center gap-2'>
<RadioGroupItem value='none' id='smtp-security-none' />
<Label
htmlFor='smtp-security-none'
className='cursor-pointer font-normal'
>
{t('No encryption')}
</Label>
</div>
<div className='flex items-center gap-2'>
<RadioGroupItem
value='ssl_tls'
id='smtp-security-ssl-tls'
/>
<Label
htmlFor='smtp-security-ssl-tls'
className='cursor-pointer font-normal'
>
{t('SSL/TLS')}
</Label>
</div>
<div className='flex items-center gap-2'>
<RadioGroupItem
value='starttls'
id='smtp-security-starttls'
/>
<Label
htmlFor='smtp-security-starttls'
className='cursor-pointer font-normal'
>
{t('STARTTLS')}
</Label>
</div>
</RadioGroup>
</FormControl>
<FormDescription>
{t('Choose one SMTP transport security mode')}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name='SMTPInsecureSkipVerify'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>
{t('Skip SMTP TLS certificate verification')}
</FormLabel>
{provider === 'resend' ? (
<>
<FormField
control={form.control}
name='ResendFrom'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Sender email address')}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete='off'
disabled={isSaving}
type='email'
placeholder='noreply@example.com'
/>
</FormControl>
<FormDescription>
{t('The domain must be verified in Resend')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ResendSenderName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Sender name')}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete='off'
disabled={isSaving}
placeholder='New API'
/>
</FormControl>
<FormDescription>
{t(
'Allow self-signed or hostname-mismatched SMTP certificates'
'The name recipients see. Leave blank to use the site name.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='SMTPForceAuthLogin'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Force AUTH LOGIN')}</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ResendApiKey'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Resend API key')}</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete='new-password'
disabled={isSaving}
placeholder={t('Enter new token to update')}
/>
</FormControl>
<FormDescription>
{t('Force SMTP authentication using AUTH LOGIN method')}
{t('Leave blank to keep the existing key')}
</FormDescription>
</SettingsSwitchContent>
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<FormField
control={form.control}
name='SMTPServer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('SMTP Host')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
placeholder={t('smtp.example.com')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Hostname or IP of your SMTP provider')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-6 md:grid-cols-2'>
<FormField
control={form.control}
name='SMTPPort'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
type='number'
placeholder='587'
{...field}
onChange={(event) =>
field.onChange(event.target.value)
}
/>
</FormControl>
<FormDescription>
{t('Common ports include 25, 465, and 587')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('SMTP encryption')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<RadioGroup
value={getSmtpSecurityMode({
SMTPSSLEnabled: smtpSSL,
SMTPStartTLSEnabled: smtpStartTLS,
})}
onValueChange={(value) => {
const mode = value as SmtpSecurityMode
form.setValue('SMTPSSLEnabled', mode === 'ssl_tls', {
shouldDirty: true,
})
form.setValue(
'SMTPStartTLSEnabled',
mode === 'starttls',
{
shouldDirty: true,
}
)
}}
className='gap-3'
>
<div className='flex items-center gap-2'>
<RadioGroupItem value='none' id='smtp-security-none' />
<Label
htmlFor='smtp-security-none'
className='cursor-pointer font-normal'
>
{t('No encryption')}
</Label>
</div>
<div className='flex items-center gap-2'>
<RadioGroupItem
value='ssl_tls'
id='smtp-security-ssl-tls'
/>
<Label
htmlFor='smtp-security-ssl-tls'
className='cursor-pointer font-normal'
>
{t('SSL/TLS')}
</Label>
</div>
<div className='flex items-center gap-2'>
<RadioGroupItem
value='starttls'
id='smtp-security-starttls'
/>
<Label
htmlFor='smtp-security-starttls'
className='cursor-pointer font-normal'
>
{t('STARTTLS')}
</Label>
</div>
</RadioGroup>
</FormControl>
</SettingsSwitchItem>
)}
/>
</div>
<FormDescription>
{t('Choose one SMTP transport security mode')}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name='SMTPAccount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
placeholder={t('noreply@example.com')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Account used when authenticating with the SMTP server')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SMTPInsecureSkipVerify'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>
{t('Skip SMTP TLS certificate verification')}
</FormLabel>
<FormDescription>
{t(
'Allow self-signed or hostname-mismatched SMTP certificates'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='SMTPFrom'
render={({ field }) => (
<FormItem>
<FormLabel>{t('From Address')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
placeholder={t('New API &lt;noreply@example.com&gt;')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Display name and email used in outgoing messages')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SMTPForceAuthLogin'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Force AUTH LOGIN')}</FormLabel>
<FormDescription>
{t(
'Force SMTP authentication using AUTH LOGIN method'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</div>
<FormField
control={form.control}
name='SMTPToken'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Password / Access Token')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
type='password'
placeholder={t('Enter new token to update')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Leave blank to keep the existing credential')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SMTPAccount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
placeholder={t('noreply@example.com')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Account used when authenticating with the SMTP server'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SMTPFrom'
render={({ field }) => (
<FormItem>
<FormLabel>{t('From Address')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
placeholder={t('New API &lt;noreply@example.com&gt;')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Display name and email used in outgoing messages')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SMTPToken'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Password / Access Token')}</FormLabel>
<FormControl>
<Input
autoComplete='off'
type='password'
placeholder={t('Enter new token to update')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Leave blank to keep the existing credential')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<SettingsControlGroup>
<FormField
control={form.control}
@@ -32,6 +32,10 @@ const defaultOperationsSettings: OperationsSettings = {
SelfUseModeEnabled: false,
QuotaRemindEnabled: true,
QuotaRemindThreshold: '',
EmailProvider: 'smtp',
ResendApiKey: '',
ResendKeyConfigured: false,
ResendFrom: '',
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
@@ -66,6 +66,10 @@ const OPERATIONS_SECTIONS = [
defaultValues={{
QuotaRemindEnabled: settings.QuotaRemindEnabled,
QuotaRemindThreshold: settings.QuotaRemindThreshold,
EmailProvider: settings.EmailProvider,
ResendApiKey: settings.ResendApiKey,
ResendKeyConfigured: settings.ResendKeyConfigured,
ResendFrom: settings.ResendFrom,
SMTPServer: settings.SMTPServer,
SMTPPort: settings.SMTPPort,
SMTPAccount: settings.SMTPAccount,
@@ -363,6 +363,10 @@ export type OperationsSettings = {
SelfUseModeEnabled: boolean
QuotaRemindEnabled: boolean
QuotaRemindThreshold: string
EmailProvider: 'smtp' | 'resend'
ResendApiKey: string
ResendKeyConfigured: boolean
ResendFrom: string
SMTPServer: string
SMTPPort: string
SMTPAccount: string
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "Configure a new custom OAuth provider for user authentication.",
"Configure a payment method for user recharge options.": "Configure a payment method for user recharge options.",
"Configure a predefined chat link for end users.": "Configure a predefined chat link for end users.",
"Configure a Resend API key before enabling Resend": "Configure a Resend API key before enabling Resend",
"Configure an input price before converting this model.": "Configure an input price before converting this model.",
"Configure and deploy a new container instance.": "Configure and deploy a new container instance.",
"Configure API documentation links for the dashboard": "Configure API documentation links for the dashboard",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "e.g., 8 means 8 local currency per USD",
"e.g., Alipay, WeChat": "e.g., Alipay, WeChat",
"e.g., Basic Package": "e.g., Basic Package",
"Email sending method": "Email sending method",
"Email sending method must be SMTP or Resend": "Email sending method must be SMTP or Resend",
"Email verification is enabled, please enter email address and verification code": "Email verification is enabled, please enter email address and verification code",
"e.g., CN2 GIA": "e.g., CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "e.g., Core APIs, OpenAI, Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "End Reason",
"End Time": "End Time",
"End time must be after start time": "End time must be after start time",
"Enter a valid sender email address": "Enter a valid sender email address",
"Enter the Discovery URL or Issuer URL first": "Enter the Discovery URL or Issuer URL first",
"End weekday": "End weekday",
"End-user identifier for abuse monitoring": "End-user identifier for abuse monitoring",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "Invalid request body.",
"Invalid request parameter format": "Invalid request parameter format",
"Invalid request parameters: {{error}}": "Invalid request parameters: {{error}}",
"Invalid Resend delivery response": "Invalid Resend delivery response",
"Includes tool-call surcharge": "Includes tool-call surcharge",
"Including failed requests, 0 = unlimited": "Including failed requests, 0 = unlimited",
"Incoming path": "Incoming path",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "Only the first {{shown}} IDs were recorded",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Only the first {{shown}} IDs were recorded ({{total}} requested)",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Only the last {{value}} log files will be retained; the rest will be deleted.",
"Only the selected method sends email. The other method keeps its settings.": "Only the selected method sends email. The other method keeps its settings.",
"Only used to find historical logs. New records are available in Audit Logs.": "Only used to find historical logs. New records are available in Audit Logs.",
"Oops! Page Not Found!": "Oops! Page Not Found!",
"Oops! Something went wrong": "Oops! Something went wrong",
@@ -5259,8 +5265,14 @@
"Reroll": "Reroll",
"Research, analysis, scientific reasoning": "Research, analysis, scientific reasoning",
"Resend ({{seconds}}s)": "Resend ({{seconds}}s)",
"Resend API key": "Resend API key",
"Resend API key is not configured or invalid": "Resend API key is not configured or invalid",
"Resend codes": "Resend codes",
"Resend email delivery failed (HTTP {{status}})": "Resend email delivery failed (HTTP {{status}})",
"Resend in {{seconds}}s": "Resend in {{seconds}}s",
"Resend key configuration status is read-only": "Resend key configuration status is read-only",
"Resend request failed": "Resend request failed",
"Resend sender address is not configured or invalid": "Resend sender address is not configured or invalid",
"Reserve requested images; settle returned images.": "Reserve requested images; settle returned images.",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.",
"Reserved for viewing complete channel keys after secure verification.": "Reserved for viewing complete channel keys after secure verification.",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.",
"Select the models to keep in this channel.": "Select the models to keep in this channel.",
"Select the task plugins installed on the upstream gateway": "Select the task plugins installed on the upstream gateway",
"Sender email address": "Sender email address",
"Sender name": "Sender name",
"Sender name must not contain line breaks or null characters": "Sender name must not contain line breaks or null characters",
"Settings updated": "Settings updated",
"Select theme preference": "Select theme preference",
"Settled by the task usage expression": "Settled by the task usage expression",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.",
"Tasks currently pending or running.": "Tasks currently pending or running.",
"Team Collaboration": "Team Collaboration",
"The name recipients see. Leave blank to use the site name.": "The name recipients see. Leave blank to use the site name.",
"The ratio config API is not enabled": "The ratio config API is not enabled",
"Technical Support": "Technical Support",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "The converted price must be a finite, non-negative number.",
"The current setting could not be loaded. You can still enter the website domain yourself.": "The current setting could not be loaded. You can still enter the website domain yourself.",
"The deployment node that handled the requests": "The deployment node that handled the requests",
"The domain must be verified in Resend": "The domain must be verified in Resend",
"This channel is not in multi-key mode": "This channel is not in multi-key mode",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "The downloaded source does not match the sha256 declared in the index. Do not install it.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.",
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "Configurer un nouveau fournisseur OAuth personnalisé pour l'authentification des utilisateurs.",
"Configure a payment method for user recharge options.": "Configurer un mode de paiement pour les options de recharge utilisateur.",
"Configure a predefined chat link for end users.": "Configurer un lien de chat prédéfini pour les utilisateurs finaux.",
"Configure a Resend API key before enabling Resend": "Configurez une clé API Resend avant d’activer Resend",
"Configure an input price before converting this model.": "Configurez un prix d’entrée avant de convertir ce modèle.",
"Configure and deploy a new container instance.": "Configurer et déployer une nouvelle instance de conteneur.",
"Configure API documentation links for the dashboard": "Configurer les liens de documentation API pour le tableau de bord",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "par ex., 8 signifie 8 unités de monnaie locale par USD",
"e.g., Alipay, WeChat": "par ex., Alipay, WeChat",
"e.g., Basic Package": "p. ex., Forfait de base",
"Email sending method": "Mode d’envoi des e-mails",
"Email sending method must be SMTP or Resend": "Le mode d’envoi des e-mails doit être SMTP ou Resend",
"Email verification is enabled, please enter email address and verification code": "La vérification de l’e-mail est activée, veuillez saisir votre adresse e-mail et le code de vérification",
"e.g., CN2 GIA": "par ex., CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "par ex., API principales, OpenAI, Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "Raison de fin",
"End Time": "Heure de fin",
"End time must be after start time": "La fin doit être postérieure au début",
"Enter a valid sender email address": "Saisissez une adresse d’expéditeur valide",
"Enter the Discovery URL or Issuer URL first": "Saisissez d’abord l’URL Discovery ou l’URL Issuer",
"End weekday": "Jour de fin",
"End-user identifier for abuse monitoring": "Identifiant d'utilisateur final pour la surveillance des abus",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "Corps de requête invalide.",
"Invalid request parameter format": "Format des paramètres de requête invalide",
"Invalid request parameters: {{error}}": "Paramètres de requête invalides : {{error}}",
"Invalid Resend delivery response": "La réponse d’envoi de Resend est invalide",
"Includes tool-call surcharge": "Inclut un supplément pour appel d’outil",
"Including failed requests, 0 = unlimited": "Y compris les requêtes échouées, 0 = illimité",
"Incoming path": "Chemin entrant",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "Seuls les {{shown}} premiers ID ont été enregistrés",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Seuls les {{shown}} premiers ID ont été enregistrés ({{total}} demandés)",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Seuls les {{value}} derniers fichiers journaux seront conservés ; le reste sera supprimé.",
"Only the selected method sends email. The other method keeps its settings.": "Seul le mode sélectionné envoie les e-mails. L’autre mode conserve ses paramètres.",
"Only used to find historical logs. New records are available in Audit Logs.": "Uniquement pour consulter les anciens journaux. Les nouveaux événements figurent dans les journaux d’audit.",
"Oops! Page Not Found!": "Oups ! Page introuvable !",
"Oops! Something went wrong": "Oups ! Quelque chose s'est mal passé",
@@ -5259,8 +5265,14 @@
"Reroll": "Relancer",
"Research, analysis, scientific reasoning": "Recherche, analyse, raisonnement scientifique",
"Resend ({{seconds}}s)": "Renvoyer ({{seconds}}s)",
"Resend API key": "Clé API Resend",
"Resend API key is not configured or invalid": "La clé API Resend est absente ou invalide",
"Resend codes": "Renvoyer les codes",
"Resend email delivery failed (HTTP {{status}})": "Resend n’a pas pu envoyer l’e-mail (HTTP {{status}})",
"Resend in {{seconds}}s": "Renvoyer dans {{seconds}} s",
"Resend key configuration status is read-only": "L’état de configuration de la clé Resend ne peut pas être modifié",
"Resend request failed": "La requête vers Resend a échoué",
"Resend sender address is not configured or invalid": "L’adresse d’expéditeur Resend est absente ou invalide",
"Reserve requested images; settle returned images.": "Réserver le montant demandé, puis facturer les images reçues.",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "Réserve le coût estimé des entrées multiplié par cette valeur, sans estimer les tokens de sortie. Valeur par défaut : 1. Les décimales positives, comme 0,5 et 1,5, sont acceptées. La facturation finale utilise la consommation réelle. Les tarifs par requête et par tâche restent inchangés.",
"Reserved for viewing complete channel keys after secure verification.": "Réservé à l'affichage des clés complètes des canaux après une vérification sécurisée.",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Sélectionnez les champs que vous souhaitez écraser avec les données en amont. Les champs non sélectionnés conservent leurs valeurs locales.",
"Select the models to keep in this channel.": "Sélectionnez les modèles à conserver dans ce canal.",
"Select the task plugins installed on the upstream gateway": "Sélectionnez les plugins de tâches installés sur la passerelle en amont",
"Sender email address": "Adresse e-mail de l’expéditeur",
"Sender name": "Nom de l’expéditeur",
"Sender name must not contain line breaks or null characters": "Le nom ne doit contenir ni saut de ligne ni caractère nul",
"Settings updated": "Paramètres mis à jour",
"Select theme preference": "Sélectionner la préférence de thème",
"Settled by the task usage expression": "Réglé par l'expression d'usage de la tâche",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Les prix d'usage des tâches sont en USD par unité déclarée. Les champs token utilisent des dollars pour 1 M de tokens ; l'éditeur écrit / 1000000 dans l'expression. Les autres unités ne sont pas divisées par un million.",
"Tasks currently pending or running.": "Tâches actuellement en attente ou en cours d’exécution.",
"Team Collaboration": "Collaboration d'équipe",
"The name recipients see. Leave blank to use the site name.": "Nom affiché aux destinataires. Laissez vide pour utiliser le nom du site.",
"The ratio config API is not enabled": "L'API de configuration des ratios n'est pas activée",
"Technical Support": "Support technique",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "Le prix converti doit être un nombre fini et positif ou nul.",
"The current setting could not be loaded. You can still enter the website domain yourself.": "Le réglage actuel n’a pas pu être chargé. Vous pouvez saisir le domaine du site manuellement.",
"The deployment node that handled the requests": "Le nœud de déploiement ayant traité les requêtes",
"The domain must be verified in Resend": "Le domaine doit être vérifié dans Resend",
"This channel is not in multi-key mode": "Ce canal n'est pas en mode multi-clés",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "Le code téléchargé ne correspond pas au sha256 déclaré dans l’index. Ne l’installez pas.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Le domaine effectif pour l'enregistrement de la clé d'accès. Doit correspondre au domaine actuel ou être son domaine parent.",
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "ユーザー認証のための新しいカスタムOAuthプロバイダーを設定します。",
"Configure a payment method for user recharge options.": "ユーザーのチャージオプションのための支払い方法を設定します。",
"Configure a predefined chat link for end users.": "エンドユーザー向けの事前定義されたチャットリンクを設定します。",
"Configure a Resend API key before enabling Resend": "Resend を有効にする前に API キーを設定してください",
"Configure an input price before converting this model.": "このモデルを変換する前に入力単価を設定してください。",
"Configure and deploy a new container instance.": "新しいコンテナインスタンスを設定してデプロイします。",
"Configure API documentation links for the dashboard": "ダッシュボード用のAPIドキュメントリンクを設定",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "例: 8 は 1 USD あたり 8 現地通貨 を意味します",
"e.g., Alipay, WeChat": "例: Alipay、WeChat",
"e.g., Basic Package": "例: 基本パッケージ",
"Email sending method": "メール送信方法",
"Email sending method must be SMTP or Resend": "メール送信方法は SMTP または Resend のみです",
"Email verification is enabled, please enter email address and verification code": "メール認証が有効です。メールアドレスと確認コードを入力してください",
"e.g., CN2 GIA": "例: CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "例: Core APIs、OpenAI、Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "終了理由",
"End Time": "終了時間",
"End time must be after start time": "終了時刻は開始時刻より後にしてください",
"Enter a valid sender email address": "有効な送信者メールアドレスを入力してください",
"Enter the Discovery URL or Issuer URL first": "先に Discovery URL または Issuer URL を入力してください",
"End weekday": "終了曜日",
"End-user identifier for abuse monitoring": "悪用検知用のエンドユーザー識別子",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "リクエスト本文が無効です。",
"Invalid request parameter format": "リクエストパラメータの形式が正しくありません",
"Invalid request parameters: {{error}}": "リクエストパラメーターが無効です: {{error}}",
"Invalid Resend delivery response": "Resend から返された送信結果が無効です",
"Includes tool-call surcharge": "ツール呼び出しの追加料金を含む",
"Including failed requests, 0 = unlimited": "失敗したリクエストを含む、0 = 無制限",
"Incoming path": "受信パス",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "先頭の {{shown}} 件の ID のみ記録されています",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "先頭の {{shown}} 件のみ記録されています(リクエスト:{{total}} 件)",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "最新の{{value}}個のログファイルのみ保持され、残りは削除されます。",
"Only the selected method sends email. The other method keeps its settings.": "選択した方法でのみメールを送信します。もう一方の方法の設定は保持されます。",
"Only used to find historical logs. New records are available in Audit Logs.": "過去のログの検索専用です。新しい記録は監査ログで確認できます。",
"Oops! Page Not Found!": "おっと!ページが見つかりません!",
"Oops! Something went wrong": "おっと!何か問題が発生しました",
@@ -5259,8 +5265,14 @@
"Reroll": "やり直し",
"Research, analysis, scientific reasoning": "リサーチ・分析・科学的推論",
"Resend ({{seconds}}s)": "再送信 ({{seconds}}秒)",
"Resend API key": "Resend API キー",
"Resend API key is not configured or invalid": "Resend API キーが未設定か無効です",
"Resend codes": "コードを再送信",
"Resend email delivery failed (HTTP {{status}})": "Resend でメールを送信できませんでした(HTTP {{status}})",
"Resend in {{seconds}}s": "{{seconds}}秒後に再送信",
"Resend key configuration status is read-only": "Resend キーの設定状態は変更できません",
"Resend request failed": "Resend へのリクエストに失敗しました",
"Resend sender address is not configured or invalid": "Resend の送信元アドレスが未設定か無効です",
"Reserve requested images; settle returned images.": "リクエストした枚数で仮課金し、返された枚数で精算します。",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "推定入力料金にこの倍率を掛けた額を確保し、出力トークンは推定しません。既定値は 1 で、0.5 や 1.5 などの正の小数を指定できます。最終料金は実際の使用量で精算します。リクエスト単位とタスクの料金には影響しません。",
"Reserved for viewing complete channel keys after secure verification.": "安全な検証後に完全なチャンネルキーを表示するために予約されています。",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "アップストリームデータで上書きしたいフィールドを選択してください。選択されていないフィールドはローカル値を保持します。",
"Select the models to keep in this channel.": "このチャネルに残すモデルを選択してください。",
"Select the task plugins installed on the upstream gateway": "上流ゲートウェイにインストールされているタスクプラグインを選択",
"Sender email address": "送信者メールアドレス",
"Sender name": "送信者名",
"Sender name must not contain line breaks or null characters": "送信者名に改行やヌル文字は使用できません",
"Settings updated": "設定を更新しました",
"Select theme preference": "テーマの好みを選択",
"Settled by the task usage expression": "タスク使用量の式で精算",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "タスク使用量の価格は宣言された単位あたりの米ドルです。token フィールドは 100 万 token あたりのドルで、エディターは式に / 1000000 を書き込みます。他の単位は 100 万では割りません。",
"Tasks currently pending or running.": "現在待機中または実行中のタスクです。",
"Team Collaboration": "チームコラボレーション",
"The name recipients see. Leave blank to use the site name.": "受信者に表示される名前です。空欄の場合はサイト名を使用します。",
"The ratio config API is not enabled": "倍率設定 API は有効になっていません",
"Technical Support": "テクニカルサポート",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "換算後の価格は有限の非負数である必要があります。",
"The current setting could not be loaded. You can still enter the website domain yourself.": "現在の設定を読み込めません。サイトのドメインを手動で入力できます。",
"The deployment node that handled the requests": "リクエストを処理したデプロイノード",
"The domain must be verified in Resend": "ドメインは Resend で認証済みである必要があります",
"This channel is not in multi-key mode": "このチャネルはマルチキーモードではありません",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "ダウンロードしたソースがインデックスに記載された sha256 と一致しません。インストールしないでください。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Passkey登録のための有効なドメイン。現在のドメインまたはその親ドメインと一致する必要があります。",
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "Настройка нового пользовательского OAuth-провайдера для аутентификации пользователей.",
"Configure a payment method for user recharge options.": "Настройте способ оплаты для опций пополнения счета пользователя.",
"Configure a predefined chat link for end users.": "Настройте предопределенную ссылку для чата для конечных пользователей.",
"Configure a Resend API key before enabling Resend": "Настройте API-ключ Resend перед включением Resend",
"Configure an input price before converting this model.": "Перед преобразованием модели настройте цену ввода.",
"Configure and deploy a new container instance.": "Настройте и разверните новый экземпляр контейнера.",
"Configure API documentation links for the dashboard": "Настроить ссылки на документацию API для панели управления",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "например, 8 означает 8 местной валюты за доллар США",
"e.g., Alipay, WeChat": "например, Alipay, WeChat",
"e.g., Basic Package": "напр., Базовый пакет",
"Email sending method": "Способ отправки почты",
"Email sending method must be SMTP or Resend": "Способ отправки почты может быть только SMTP или Resend",
"Email verification is enabled, please enter email address and verification code": "Включена проверка электронной почты, введите адрес электронной почты и код подтверждения",
"e.g., CN2 GIA": "например, CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "например, Core APIs, OpenAI, Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "Причина завершения",
"End Time": "Время окончания",
"End time must be after start time": "Время окончания должно быть позже начала",
"Enter a valid sender email address": "Введите корректный адрес отправителя",
"Enter the Discovery URL or Issuer URL first": "Сначала укажите Discovery URL или Issuer URL",
"End weekday": "Конечный день недели",
"End-user identifier for abuse monitoring": "Идентификатор конечного пользователя для мониторинга злоупотреблений",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "Недопустимое тело запроса.",
"Invalid request parameter format": "Неверный формат параметров запроса",
"Invalid request parameters: {{error}}": "Недопустимые параметры запроса: {{error}}",
"Invalid Resend delivery response": "Resend вернул некорректный ответ об отправке",
"Includes tool-call surcharge": "Включает доплату за вызов инструмента",
"Including failed requests, 0 = unlimited": "Включая неудачные запросы, 0 = без ограничений",
"Incoming path": "Входящий путь",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "Записаны только первые {{shown}} ID",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Записаны только первые {{shown}} ID (запрошено: {{total}})",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Будут сохранены только последние {{value}} файлов журналов; остальные будут удалены.",
"Only the selected method sends email. The other method keeps its settings.": "Почта отправляется только выбранным способом. Настройки другого способа сохраняются.",
"Only used to find historical logs. New records are available in Audit Logs.": "Только для поиска старых журналов. Новые записи доступны в журнале аудита.",
"Oops! Page Not Found!": "Ой! Страница не найдена!",
"Oops! Something went wrong": "Ой! Что-то пошло не так",
@@ -5259,8 +5265,14 @@
"Reroll": "Повторить",
"Research, analysis, scientific reasoning": "Исследования, анализ, научные рассуждения",
"Resend ({{seconds}}s)": "Отправить повторно ({{seconds}}с)",
"Resend API key": "API-ключ Resend",
"Resend API key is not configured or invalid": "API-ключ Resend не задан или недействителен",
"Resend codes": "Отправить коды повторно",
"Resend email delivery failed (HTTP {{status}})": "Resend не смог отправить письмо (HTTP {{status}})",
"Resend in {{seconds}}s": "Повторная отправка через {{seconds}} с",
"Resend key configuration status is read-only": "Состояние настройки ключа Resend нельзя изменить",
"Resend request failed": "Не удалось выполнить запрос к Resend",
"Resend sender address is not configured or invalid": "Адрес отправителя Resend не задан или недействителен",
"Reserve requested images; settle returned images.": "Резервирование по запрошенному количеству, расчёт — по полученному.",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "Резервируется оценочная стоимость ввода, умноженная на это значение, без оценки выходных токенов. По умолчанию 1; допустимы положительные дроби, например 0,5 и 1,5. Итоговая плата зависит от фактического использования. Тарифы за запрос и за задачу не меняются.",
"Reserved for viewing complete channel keys after secure verification.": "Зарезервировано для просмотра полных ключей каналов после безопасной проверки.",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Выберите поля, которые вы хотите перезаписать данными из вышестоящего источника. Невыбранные поля сохранят свои локальные значения.",
"Select the models to keep in this channel.": "Выберите модели, которые нужно оставить в этом канале.",
"Select the task plugins installed on the upstream gateway": "Выберите плагины задач, установленные на вышестоящем шлюзе",
"Sender email address": "Адрес отправителя",
"Sender name": "Имя отправителя",
"Sender name must not contain line breaks or null characters": "Имя отправителя не должно содержать переносы строк или нулевые символы",
"Settings updated": "Настройки обновлены",
"Select theme preference": "Выбрать предпочтение темы",
"Settled by the task usage expression": "Расчёт по выражению использования задачи",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Цены за использование задач указаны в USD за объявленную единицу. Поля token задаются в долларах за 1 млн token; редактор записывает / 1000000 в выражение. Остальные единицы на миллион не делятся.",
"Tasks currently pending or running.": "Задачи, которые ожидают выполнения или выполняются сейчас.",
"Team Collaboration": "Совместная работа в команде",
"The name recipients see. Leave blank to use the site name.": "Имя, которое увидят получатели. Оставьте пустым для использования названия сайта.",
"The ratio config API is not enabled": "API конфигурации коэффициентов не включён",
"Technical Support": "Техническая поддержка",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "Цена после пересчёта должна быть конечным неотрицательным числом.",
"The current setting could not be loaded. You can still enter the website domain yourself.": "Не удалось загрузить текущую настройку. Вы можете ввести домен сайта вручную.",
"The deployment node that handled the requests": "Узел развёртывания, обработавший запросы",
"The domain must be verified in Resend": "Домен должен быть подтверждён в Resend",
"This channel is not in multi-key mode": "Этот канал не в режиме нескольких ключей",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "Загруженный код не соответствует sha256, указанному в индексе. Не устанавливайте его.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Действующий домен для регистрации Passkey. Должен совпадать с текущим доменом или быть его родительским доменом.",
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "Cấu hình nhà cung cấp OAuth tùy chỉnh mới để xác thực người dùng.",
"Configure a payment method for user recharge options.": "Cấu hình phương thức thanh toán cho các tùy chọn nạp tiền của người dùng.",
"Configure a predefined chat link for end users.": "Cấu hình một liên kết trò chuyện được xác định trước cho người dùng cuối.",
"Configure a Resend API key before enabling Resend": "Cấu hình khóa API Resend trước khi bật Resend",
"Configure an input price before converting this model.": "Hãy cấu hình giá đầu vào trước khi chuyển đổi mô hình này.",
"Configure and deploy a new container instance.": "Cấu hình và triển khai một phiên bản container mới.",
"Configure API documentation links for the dashboard": "Cấu hình các liên kết tài liệu API cho bảng điều khiển",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "Ví dụ, 8 có nghĩa là 8 đơn vị tiền tệ địa phương trên mỗi USD",
"e.g., Alipay, WeChat": "ví dụ: Alipay, WeChat",
"e.g., Basic Package": "ví dụ, Gói cơ bản",
"Email sending method": "Phương thức gửi email",
"Email sending method must be SMTP or Resend": "Phương thức gửi email chỉ có thể là SMTP hoặc Resend",
"Email verification is enabled, please enter email address and verification code": "Xác minh email đã được bật, vui lòng nhập địa chỉ email và mã xác minh",
"e.g., CN2 GIA": "ví dụ, CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "ví dụ: Core APIs, OpenAI, Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "Lý do kết thúc",
"End Time": "Thời gian kết thúc",
"End time must be after start time": "Thời gian kết thúc phải sau thời gian bắt đầu",
"Enter a valid sender email address": "Nhập địa chỉ email người gửi hợp lệ",
"Enter the Discovery URL or Issuer URL first": "Vui lòng nhập Discovery URL hoặc Issuer URL trước",
"End weekday": "Ngày kết thúc",
"End-user identifier for abuse monitoring": "Định danh người dùng cuối để giám sát lạm dụng",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "Nội dung yêu cầu không hợp lệ.",
"Invalid request parameter format": "Định dạng tham số yêu cầu không hợp lệ",
"Invalid request parameters: {{error}}": "Tham số yêu cầu không hợp lệ: {{error}}",
"Invalid Resend delivery response": "Kết quả gửi do Resend trả về không hợp lệ",
"Includes tool-call surcharge": "Bao gồm phụ phí gọi công cụ",
"Including failed requests, 0 = unlimited": "Bao gồm các yêu cầu thất bại, 0 = không giới hạn",
"Incoming path": "Path đầu vào",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "Chỉ ghi nhận {{shown}} ID đầu tiên",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "Chỉ ghi nhận {{shown}} ID đầu tiên (đã yêu cầu {{total}})",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "Chỉ giữ lại {{value}} tệp nhật ký gần nhất; phần còn lại sẽ bị xóa.",
"Only the selected method sends email. The other method keeps its settings.": "Chỉ phương thức đã chọn được dùng để gửi email. Cấu hình của phương thức còn lại vẫn được giữ.",
"Only used to find historical logs. New records are available in Audit Logs.": "Chỉ dùng để tìm nhật ký cũ. Bản ghi mới có trong Nhật ký kiểm toán.",
"Oops! Page Not Found!": "Ối! Không tìm thấy trang!",
"Oops! Something went wrong": "Oops! An error occurred.",
@@ -5259,8 +5265,14 @@
"Reroll": "Quay lại",
"Research, analysis, scientific reasoning": "Nghiên cứu, phân tích, suy luận khoa học",
"Resend ({{seconds}}s)": "Gửi lại ({{seconds}}s)",
"Resend API key": "Khóa API Resend",
"Resend API key is not configured or invalid": "Khóa API Resend chưa được cấu hình hoặc không hợp lệ",
"Resend codes": "Gửi lại mã",
"Resend email delivery failed (HTTP {{status}})": "Resend không gửi được email (HTTP {{status}})",
"Resend in {{seconds}}s": "Gửi lại sau {{seconds}} giây",
"Resend key configuration status is read-only": "Không thể thay đổi trạng thái cấu hình khóa Resend",
"Resend request failed": "Yêu cầu tới Resend thất bại",
"Resend sender address is not configured or invalid": "Địa chỉ người gửi Resend chưa được cấu hình hoặc không hợp lệ",
"Reserve requested images; settle returned images.": "Tạm trừ theo số ảnh yêu cầu, quyết toán theo số ảnh nhận được.",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "Giữ trước chi phí đầu vào ước tính nhân với hệ số này, không ước tính token đầu ra. Mặc định là 1; hỗ trợ số thập phân dương như 0,5 và 1,5. Phí cuối cùng dựa trên mức sử dụng thực tế. Giá theo yêu cầu và tác vụ không bị ảnh hưởng.",
"Reserved for viewing complete channel keys after secure verification.": "Dành riêng để xem khóa kênh đầy đủ sau khi xác minh bảo mật.",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Chọn các trường bạn muốn ghi đè bằng dữ liệu thượng nguồn. Các trường không được chọn sẽ giữ nguyên giá trị cục bộ của chúng.",
"Select the models to keep in this channel.": "Chọn các mô hình cần giữ lại trong kênh này.",
"Select the task plugins installed on the upstream gateway": "Chọn các plugin tác vụ đã cài trên gateway thượng nguồn",
"Sender email address": "Địa chỉ email người gửi",
"Sender name": "Tên người gửi",
"Sender name must not contain line breaks or null characters": "Tên người gửi không được chứa ký tự xuống dòng hoặc ký tự rỗng",
"Settings updated": "Đã cập nhật cài đặt",
"Select theme preference": "Chọn chủ đề ưu tiên",
"Settled by the task usage expression": "Quyết toán theo biểu thức mức dùng của tác vụ",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Giá sử dụng tác vụ tính bằng USD trên đơn vị đã khai báo. Trường token dùng USD trên 1 triệu token; trình soạn thảo ghi / 1000000 vào biểu thức. Các đơn vị khác không chia cho một triệu.",
"Tasks currently pending or running.": "Các tác vụ hiện đang chờ hoặc đang chạy.",
"Team Collaboration": "Teamwork",
"The name recipients see. Leave blank to use the site name.": "Tên hiển thị với người nhận. Để trống để dùng tên trang web.",
"The ratio config API is not enabled": "API cấu hình hệ số chưa được bật",
"Technical Support": "Hỗ trợ kỹ thuật",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "Giá sau quy đổi phải là số hữu hạn không âm.",
"The current setting could not be loaded. You can still enter the website domain yourself.": "Không tải được cài đặt hiện tại. Bạn vẫn có thể tự nhập tên miền trang.",
"The deployment node that handled the requests": "Nút triển khai đã xử lý các yêu cầu",
"The domain must be verified in Resend": "Tên miền phải được xác minh trên Resend",
"This channel is not in multi-key mode": "Kênh này không ở chế độ đa khóa",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "Mã nguồn đã tải không khớp với sha256 khai báo trong chỉ mục. Đừng cài đặt nó.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Mi",
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "設定新的自訂 OAuth 供應商用於用戶認證。",
"Configure a payment method for user recharge options.": "設定用於用戶儲值選項的支付方式。",
"Configure a predefined chat link for end users.": "為終端用戶設定預先定義的聊天連結。",
"Configure a Resend API key before enabling Resend": "請先設定 Resend API 金鑰,再啟用 Resend",
"Configure an input price before converting this model.": "請先設定輸入價格,再轉換此模型。",
"Configure and deploy a new container instance.": "設定並部署一個新的容器實例。",
"Configure API documentation links for the dashboard": "設定儀表板的 API 文件連結",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "例如,8 表示每美元兌換 8 單位本地貨幣",
"e.g., Alipay, WeChat": "例如,支付寶,微信",
"e.g., Basic Package": "例如,基本套餐",
"Email sending method": "郵件傳送方式",
"Email sending method must be SMTP or Resend": "郵件傳送方式只能是 SMTP 或 Resend",
"Email verification is enabled, please enter email address and verification code": "管理員開啟了電子郵件驗證,請輸入電子郵件地址和驗證碼",
"e.g., CN2 GIA": "例如,CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "例如,核心 API,OpenAI,Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "結束原因",
"End Time": "結束時間",
"End time must be after start time": "結束時間必須晚於開始時間",
"Enter a valid sender email address": "請輸入有效的寄件信箱地址",
"Enter the Discovery URL or Issuer URL first": "請先填寫 Discovery URL 或 Issuer URL",
"End weekday": "結束星期",
"End-user identifier for abuse monitoring": "用於風險審計的終端用戶標識",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "無效的請求內容。",
"Invalid request parameter format": "請求參數格式錯誤",
"Invalid request parameters: {{error}}": "無效的請求參數: {{error}}",
"Invalid Resend delivery response": "Resend 回傳的傳送結果無效",
"Includes tool-call surcharge": "包含工具呼叫附加費",
"Including failed requests, 0 = unlimited": "包括失敗的請求,0 = 無限制",
"Incoming path": "入口路徑",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "僅記錄前 {{shown}} 項 ID",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "僅記錄前 {{shown}} 項,共請求 {{total}} 項",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "將只保留最近 {{value}} 個日誌檔案,其餘將被刪除。",
"Only the selected method sends email. The other method keeps its settings.": "只用所選方式傳送郵件,另一種方式的設定會保留。",
"Only used to find historical logs. New records are available in Audit Logs.": "僅用於查詢歷史日誌,新記錄請前往稽核日誌查看。",
"Oops! Page Not Found!": "糟糕!頁面未找到!",
"Oops! Something went wrong": "糟糕!出錯了",
@@ -5259,8 +5265,14 @@
"Reroll": "重繪",
"Research, analysis, scientific reasoning": "研究、分析與科學推理",
"Resend ({{seconds}}s)": "重新發送 ({{seconds}}s)",
"Resend API key": "Resend API 金鑰",
"Resend API key is not configured or invalid": "Resend API 金鑰未設定或無效",
"Resend codes": "重新傳送驗證碼",
"Resend email delivery failed (HTTP {{status}})": "Resend 傳送郵件失敗(HTTP {{status}})",
"Resend in {{seconds}}s": "{{seconds}} 秒後重送",
"Resend key configuration status is read-only": "Resend 金鑰的設定狀態不能手動修改",
"Resend request failed": "請求 Resend 失敗",
"Resend sender address is not configured or invalid": "Resend 寄件信箱未設定或無效",
"Reserve requested images; settle returned images.": "按請求數量預扣,按實際傳回數量結算。",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "按預估輸入費用乘以此倍率預扣,不預估輸出 token。預設為 1,支援 0.5、1.5 等正小數。最終按實際用量結算,按次和任務計費不受影響。",
"Reserved for viewing complete channel keys after secure verification.": "預留用於在安全驗證後查看完整渠道金鑰。",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "選擇要使用上游數據覆蓋的欄位。未選擇的欄位將保留其本地值。",
"Select the models to keep in this channel.": "選擇要在此渠道中保留的模型。",
"Select the task plugins installed on the upstream gateway": "選擇上游閘道已安裝的任務插件",
"Sender email address": "寄件信箱地址",
"Sender name": "寄件人名稱",
"Sender name must not contain line breaks or null characters": "寄件人名稱不能包含換行符號或空字元",
"Settings updated": "設定已更新",
"Select theme preference": "選擇主題偏好",
"Settled by the task usage expression": "按任務用量表達式結算",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "任務用量價格依宣告單位以美元計。token 欄位依每 100 萬 token 的美元價輸入,編輯器會在表達式中寫入 / 1000000。其他單位不會除以一百萬。",
"Tasks currently pending or running.": "目前等待中或執行中的任務。",
"Team Collaboration": "團隊協作",
"The name recipients see. Leave blank to use the site name.": "收件人看到的寄件人名稱,留空使用網站名稱。",
"The ratio config API is not enabled": "倍率設定介面未啟用",
"Technical Support": "技術支援",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "換算後的價格必須是有限的非負數。",
"The current setting could not be loaded. You can still enter the website domain yourself.": "暫時無法讀取目前設定,你仍可以手動填寫網站網域。",
"The deployment node that handled the requests": "處理請求的部署節點",
"The domain must be verified in Resend": "網域須已在 Resend 完成驗證",
"This channel is not in multi-key mode": "該渠道不是多金鑰模式",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "下載到的原始碼與索引宣告的 sha256 不一致,請勿安裝。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用於 Passkey 註冊的有效域。必須與目前域匹配或為其父域。",
+17
View File
@@ -1353,6 +1353,7 @@
"Configure a new custom OAuth provider for user authentication.": "配置新的自定义OAuth提供商用于用户认证。",
"Configure a payment method for user recharge options.": "配置用于用户充值选项的支付方式。",
"Configure a predefined chat link for end users.": "为终端用户配置预定义的聊天链接。",
"Configure a Resend API key before enabling Resend": "请先配置 Resend API 密钥,再启用 Resend",
"Configure an input price before converting this model.": "请先配置输入价格,再转换此模型。",
"Configure and deploy a new container instance.": "配置并部署一个新的容器实例。",
"Configure API documentation links for the dashboard": "配置仪表板的 API 文档链接",
@@ -2086,6 +2087,8 @@
"e.g., 8 means 8 local currency per USD": "例如,8 表示每美元兑换 8 单位本地货币",
"e.g., Alipay, WeChat": "例如,支付宝,微信",
"e.g., Basic Package": "例如,基本套餐",
"Email sending method": "邮件发送方式",
"Email sending method must be SMTP or Resend": "邮件发送方式只能是 SMTP 或 Resend",
"Email verification is enabled, please enter email address and verification code": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
"e.g., CN2 GIA": "例如,CN2 GIA",
"e.g., Core APIs, OpenAI, Claude": "例如,核心 API,OpenAI,Claude",
@@ -2247,6 +2250,7 @@
"End Reason": "结束原因",
"End Time": "结束时间",
"End time must be after start time": "结束时间必须晚于开始时间",
"Enter a valid sender email address": "请输入有效的发件邮箱地址",
"Enter the Discovery URL or Issuer URL first": "请先填写 Discovery URL 或 Issuer URL",
"End weekday": "结束星期",
"End-user identifier for abuse monitoring": "用于风险审计的终端用户标识",
@@ -3201,6 +3205,7 @@
"Invalid request body.": "无效的请求体。",
"Invalid request parameter format": "请求参数格式错误",
"Invalid request parameters: {{error}}": "无效的请求参数: {{error}}",
"Invalid Resend delivery response": "Resend 返回的发送结果无效",
"Includes tool-call surcharge": "包含工具调用附加费",
"Including failed requests, 0 = unlimited": "包括失败的请求,0 = 无限制",
"Incoming path": "入口路径",
@@ -4303,6 +4308,7 @@
"Only the first {{shown}} IDs were recorded": "仅记录前 {{shown}} 项 ID",
"Only the first {{shown}} IDs were recorded ({{total}} requested)": "仅记录前 {{shown}} 项,共请求 {{total}} 项",
"Only the last {{value}} log files will be retained; the rest will be deleted.": "将只保留最近 {{value}} 个日志文件,其余将被删除。",
"Only the selected method sends email. The other method keeps its settings.": "只用所选方式发送邮件,另一种方式的配置会保留。",
"Only used to find historical logs. New records are available in Audit Logs.": "仅用于查询历史日志,新记录请前往审计日志查看。",
"Oops! Page Not Found!": "糟糕!页面未找到!",
"Oops! Something went wrong": "糟糕!出错了",
@@ -5259,8 +5265,14 @@
"Reroll": "重绘",
"Research, analysis, scientific reasoning": "研究、分析与科学推理",
"Resend ({{seconds}}s)": "重新发送 ({{seconds}}s)",
"Resend API key": "Resend API 密钥",
"Resend API key is not configured or invalid": "Resend API 密钥未配置或无效",
"Resend codes": "重新发送验证码",
"Resend email delivery failed (HTTP {{status}})": "Resend 发送邮件失败(HTTP {{status}})",
"Resend in {{seconds}}s": "{{seconds}} 秒后重发",
"Resend key configuration status is read-only": "Resend 密钥的配置状态不能手动修改",
"Resend request failed": "请求 Resend 失败",
"Resend sender address is not configured or invalid": "Resend 发件邮箱未配置或无效",
"Reserve requested images; settle returned images.": "按请求数量预扣,按实际返回数量结算。",
"Reserve the estimated input cost multiplied by this value, without estimating output tokens. Defaults to 1; positive decimals such as 0.5 and 1.5 are supported. Final charges use actual usage. Per-request and task prices are unaffected.": "按预计输入费用乘以此倍率预扣,不预估输出 token。默认为 1,支持 0.5、1.5 等正小数。最终按实际用量结算,按次和任务计费不受影响。",
"Reserved for viewing complete channel keys after secure verification.": "预留用于在安全验证后查看完整渠道密钥。",
@@ -5651,6 +5663,9 @@
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "选择要使用上游数据覆盖的字段。未选择的字段将保留其本地值。",
"Select the models to keep in this channel.": "选择要在此渠道中保留的模型。",
"Select the task plugins installed on the upstream gateway": "选择上游网关已安装的任务插件",
"Sender email address": "发件邮箱地址",
"Sender name": "发件人名称",
"Sender name must not contain line breaks or null characters": "发件人名称不能包含换行符或空字符",
"Settings updated": "设置已更新",
"Select theme preference": "选择主题偏好",
"Settled by the task usage expression": "按任务用量表达式结算",
@@ -6141,6 +6156,7 @@
"Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "任务用量价格按声明单位以美元计。token 字段按每 100 万 token 的美元价输入,编辑器会在表达式中写入 / 1000000。其他单位不会除以一百万。",
"Tasks currently pending or running.": "当前等待中或运行中的任务。",
"Team Collaboration": "团队协作",
"The name recipients see. Leave blank to use the site name.": "收件人看到的发件人名称,留空使用站点名称。",
"The ratio config API is not enabled": "倍率配置接口未启用",
"Technical Support": "技术支持",
"Telegram": "Telegram",
@@ -6211,6 +6227,7 @@
"The converted price must be a finite, non-negative number.": "换算后的价格必须是有限的非负数。",
"The current setting could not be loaded. You can still enter the website domain yourself.": "暂时无法读取当前设置,你仍可以手动填写网站域名。",
"The deployment node that handled the requests": "处理请求的部署节点",
"The domain must be verified in Resend": "域名须已在 Resend 完成验证",
"This channel is not in multi-key mode": "该渠道不是多密钥模式",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "下载到的源码与索引声明的 sha256 不一致,请勿安装。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。",