feat: add SEND_MAIL delivery and quota controls (#986)

* feat: add SEND_MAIL delivery and quota controls

* test: cover -1 unlimited runtime for send mail quota

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: split send limit validation and save

* refactor: move send limit counters to settings

* fix: polish send mail limit review follow-ups

* docs: note SEND_MAIL breaking change

* test: align send mail limit e2e with new messages

* fix: address review follow-ups

* fix: harden admin send mail handlers

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2026-04-17 11:37:14 +08:00
committed by GitHub
parent a5aa475380
commit e772db8c3e
16 changed files with 1032 additions and 54 deletions

View File

@@ -21,6 +21,12 @@ const { t } = useI18n({
send_address_block_list: 'Address Block Keywords for send email',
noLimitSendAddressList: 'No Balance Limit Send Address List',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
send_mail_limit: 'Send Mail Limit',
send_mail_limit_tip: 'This applies to all send channels. Use -1 for unlimited and 0 to block sending.',
send_mail_daily_limit: 'Daily Limit',
send_mail_monthly_limit: 'Monthly Limit',
send_mail_daily_limit_invalid: 'Daily limit must be an integer greater than or equal to -1',
send_mail_monthly_limit_invalid: 'Monthly limit must be an integer greater than or equal to -1',
fromBlockList: 'Block Keywords for receive email',
block_receive_unknow_address_email: 'Block receive unknow address email',
email_forwarding_config: 'Email Forwarding Configuration',
@@ -65,6 +71,12 @@ const { t } = useI18n({
send_address_block_list: '发送邮件地址屏蔽关键词',
noLimitSendAddressList: '无余额限制发送地址列表',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
send_mail_limit: '发信额度',
send_mail_limit_tip: '对全部发信渠道生效。-1 表示无限0 表示禁止发送。',
send_mail_daily_limit: '每日额度',
send_mail_monthly_limit: '每月额度',
send_mail_daily_limit_invalid: '每日额度必须是大于等于 -1 的整数',
send_mail_monthly_limit_invalid: '每月额度必须是大于等于 -1 的整数',
fromBlockList: '接收邮件地址屏蔽关键词',
block_receive_unknow_address_email: '禁止接收未知地址邮件',
email_forwarding_config: '邮件转发配置',
@@ -116,7 +128,13 @@ const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
FORCE_ENABLE: 'force_enable',
FORCE_DISABLE: 'force_disable'
}
const DEFAULT_SEND_MAIL_DAILY_LIMIT = 100
const DEFAULT_SEND_MAIL_MONTHLY_LIMIT = 3000
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
const sendMailDailyLimitEnabled = ref(false)
const sendMailMonthlyLimitEnabled = ref(false)
const sendMailDailyLimit = ref(DEFAULT_SEND_MAIL_DAILY_LIMIT)
const sendMailMonthlyLimit = ref(DEFAULT_SEND_MAIL_MONTHLY_LIMIT)
const addressCreationSubdomainMatchStatus = ref({
envConfigured: false,
envEnabled: false,
@@ -314,6 +332,31 @@ const getSubdomainMatchPayloadValue = (mode) => {
return null
}
const getSendMailLimitPayload = () => {
return {
dailyEnabled: sendMailDailyLimitEnabled.value,
monthlyEnabled: sendMailMonthlyLimitEnabled.value,
dailyLimit: sendMailDailyLimitEnabled.value ? sendMailDailyLimit.value : null,
monthlyLimit: sendMailMonthlyLimitEnabled.value ? sendMailMonthlyLimit.value : null
}
}
const isValidSendMailLimit = (value) => {
return Number.isInteger(value) && value >= -1
}
const validateSendMailLimit = () => {
if (sendMailDailyLimitEnabled.value && !isValidSendMailLimit(sendMailDailyLimit.value)) {
message.error(t('send_mail_daily_limit_invalid'))
return false
}
if (sendMailMonthlyLimitEnabled.value && !isValidSendMailLimit(sendMailMonthlyLimit.value)) {
message.error(t('send_mail_monthly_limit_invalid'))
return false
}
return true
}
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
try {
const res = await api.fetch(`/admin/account_settings`)
@@ -337,6 +380,15 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
addressCreationSubdomainMatchStatus.value.storedEnabled
)
const sendMailLimitConfig = res.sendMailLimitConfig
sendMailDailyLimitEnabled.value = !!sendMailLimitConfig?.dailyEnabled
sendMailMonthlyLimitEnabled.value = !!sendMailLimitConfig?.monthlyEnabled
sendMailDailyLimit.value = sendMailDailyLimitEnabled.value
? sendMailLimitConfig.dailyLimit
: DEFAULT_SEND_MAIL_DAILY_LIMIT
sendMailMonthlyLimit.value = sendMailMonthlyLimitEnabled.value
? sendMailLimitConfig.monthlyLimit
: DEFAULT_SEND_MAIL_MONTHLY_LIMIT
} catch (error) {
if (!suppressErrorMessage) {
message.error(error.message || "error");
@@ -346,6 +398,9 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
}
const save = async () => {
if (!validateSendMailLimit()) {
return
}
try {
const payload = {
blockList: addressBlockList.value || [],
@@ -356,7 +411,8 @@ const save = async () => {
emailRuleSettings: emailRuleSettings.value,
addressCreationSettings: {
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
}
},
sendMailLimitConfig: getSendMailLimitPayload()
}
await api.fetch(`/admin/account_settings`, {
method: 'POST',
@@ -437,6 +493,35 @@ onMounted(async () => {
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('send_mail_limit')">
<n-flex vertical style="width: 100%;">
<n-flex justify="space-between" align="center">
<n-text>{{ t('send_mail_daily_limit') }}</n-text>
<n-flex align="center">
<n-switch v-model:value="sendMailDailyLimitEnabled" :round="false" />
<n-input-number
v-model:value="sendMailDailyLimit"
:disabled="!sendMailDailyLimitEnabled"
:min="-1"
/>
</n-flex>
</n-flex>
<n-flex justify="space-between" align="center">
<n-text>{{ t('send_mail_monthly_limit') }}</n-text>
<n-flex align="center">
<n-switch v-model:value="sendMailMonthlyLimitEnabled" :round="false" />
<n-input-number
v-model:value="sendMailMonthlyLimit"
:disabled="!sendMailMonthlyLimitEnabled"
:min="-1"
/>
</n-flex>
</n-flex>
<n-text depth="3">
{{ t('send_mail_limit_tip') }}
</n-text>
</n-flex>
</n-form-item-row>
<n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
<template #empty>