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

@@ -8,8 +8,14 @@
## v1.7.0(main)
### Breaking Changes
- breaking: |发信| `SEND_MAIL` 的语义已从“仅用于 `verifiedAddressList` 命中的兼容发信路径”调整为“常规兜底发信通道”。如果实例已绑定 `SEND_MAIL` 且未配置 Resend/SMTP升级后未命中 `verifiedAddressList` 的收件人也会直接通过 Cloudflare binding 发出,发信行为与成本路径会发生变化
### Features
- feat: |发信| 推荐使用 Cloudflare `send_email` binding 作为默认发信通道,已 onboard Email Routing 的域名未配置 Resend/SMTP 时自动走 binding 发至任意地址Workers Paid 每月含 3000 封,超出 $0.35/1000 封);历史 `verifiedAddressList` / Resend / SMTP 配置完全兼容(#964
### Bug Fixes
- fix: |用户侧收件箱| 修复 `ENABLE_USER_DELETE_EMAIL` 关闭时用户中心仍显示删除按钮且仍可通过 `/user_api/mails/:id` 删除邮件的问题(#978

View File

@@ -8,8 +8,14 @@
## v1.7.0(main)
### Breaking Changes
- breaking: |send mail| `SEND_MAIL` semantics changed from a verified-address-only compatibility path to a normal fallback send channel. If an instance already binds `SEND_MAIL` and does not configure Resend/SMTP, recipients outside `verifiedAddressList` will now also be sent through the Cloudflare binding after upgrade, changing runtime behavior and cost routing
### Features
- feat: |send mail| Recommend Cloudflare `send_email` binding as the default send channel. Domains onboarded to Email Routing without Resend/SMTP now automatically use the binding to send to arbitrary addresses (Workers Paid includes 3,000 msgs/month, $0.35/1000 beyond); existing `verifiedAddressList` / Resend / SMTP configurations remain fully compatible (#964)
### Bug Fixes
- fix: |User Mailbox| Fix an issue where the user center still showed delete actions and could still delete mail via `/user_api/mails/:id` when `ENABLE_USER_DELETE_EMAIL` was disabled (#978)

View File

@@ -0,0 +1,488 @@
import { test, expect, APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage,
} from '../../fixtures/test-helpers';
const ADMIN_PASSWORD = 'e2e-admin-pass';
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
const DEFAULT_ACCOUNT_SETTINGS = {
blockList: [],
sendBlockList: [],
verifiedAddressList: [],
fromBlockList: [],
noLimitSendAddressList: [],
emailRuleSettings: {},
addressCreationSettings: {},
};
const DISABLED_LIMIT_CONFIG = {
dailyEnabled: false,
monthlyEnabled: false,
dailyLimit: null as number | null,
monthlyLimit: null as number | null,
};
async function saveLimitConfig(
request: APIRequestContext,
sendMailLimitConfig: Record<string, unknown>
) {
return request.post(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
data: { ...DEFAULT_ACCOUNT_SETTINGS, sendMailLimitConfig },
});
}
async function resetLimitConfig(request: APIRequestContext) {
const res = await saveLimitConfig(request, DISABLED_LIMIT_CONFIG);
expect(res.ok()).toBe(true);
}
async function sendOneMail(
request: APIRequestContext,
jwt: string,
tag: string,
opts: { expectDelivery?: boolean; lang?: string } = {}
) {
const { expectDelivery = true, lang } = opts;
const subject = `limit-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const headers: Record<string, string> = { Authorization: `Bearer ${jwt}` };
if (lang) headers['x-lang'] = lang;
let listener: ReturnType<typeof onMailpitMessage> | undefined;
if (expectDelivery) {
listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
}
const res = await request.post(`${WORKER_URL}/api/send_mail`, {
headers,
data: {
from_name: 'Limit E2E',
to_name: 'Recipient',
to_mail: 'recipient@test.example.com',
subject,
content: `Limit test body ${tag}`,
is_html: false,
},
});
return { res, listener, subject };
}
async function probeLimitBaseline(
request: APIRequestContext,
jwt: string,
config: {
dailyEnabled: boolean;
monthlyEnabled: boolean;
dailyLimit: number | null;
monthlyLimit: number | null;
},
subjectPrefix: string,
maxProbeLimit: number = 50
): Promise<number> {
for (let limit = 1; limit <= maxProbeLimit; limit++) {
const save = await saveLimitConfig(request, {
...config,
dailyLimit: config.dailyEnabled ? limit : null,
monthlyLimit: config.monthlyEnabled ? limit : null,
});
expect(save.ok()).toBe(true);
const probe = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: 'probe',
to_name: '',
to_mail: 'recipient@test.example.com',
subject: `${subjectPrefix}-${limit}-${Date.now()}`,
content: 'probe',
is_html: false,
},
});
if (probe.ok()) {
return limit;
}
}
throw new Error(`Failed to probe send mail limit baseline within ${maxProbeLimit}`);
}
async function probeDailyBaseline(
request: APIRequestContext,
jwt: string
): Promise<number> {
return probeLimitBaseline(request, jwt, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: 1,
monthlyLimit: null,
}, 'probe-daily');
}
async function probeMonthlyBaseline(
request: APIRequestContext,
jwt: string
): Promise<number> {
return probeLimitBaseline(request, jwt, {
dailyEnabled: false,
monthlyEnabled: true,
dailyLimit: null,
monthlyLimit: 1,
}, 'probe-monthly');
}
test.describe('Send Mail Limit', () => {
test.beforeEach(async ({ request }) => {
await deleteAllMailpitMessages(request);
await resetLimitConfig(request);
});
test.afterEach(async ({ request }) => {
await resetLimitConfig(request);
});
test('save + read roundtrip preserves all fields', async ({ request }) => {
const config = {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: 7,
monthlyLimit: 1234,
};
const save = await saveLimitConfig(request, config);
expect(save.ok()).toBe(true);
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
});
expect(read.ok()).toBe(true);
const body = await read.json();
expect(body.sendMailLimitConfig).toEqual(config);
});
test('disabled flags coerce numeric limits to null', async ({ request }) => {
const save = await saveLimitConfig(request, {
dailyEnabled: false,
monthlyEnabled: false,
dailyLimit: 10,
monthlyLimit: 20,
});
expect(save.ok()).toBe(true);
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
});
const body = await read.json();
expect(body.sendMailLimitConfig).toEqual(DISABLED_LIMIT_CONFIG);
});
test('minus one is accepted as unlimited', async ({ request }) => {
const config = {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: -1,
monthlyLimit: -1,
};
const save = await saveLimitConfig(request, config);
expect(save.ok()).toBe(true);
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
});
const body = await read.json();
expect(body.sendMailLimitConfig).toEqual(config);
});
test('invalid payloads rejected with 400', async ({ request }) => {
const cases: Array<Record<string, unknown>> = [
{ dailyEnabled: 'yes', monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: -2, monthlyLimit: null },
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: 1.5, monthlyLimit: null },
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
{ dailyEnabled: false, monthlyEnabled: true, dailyLimit: null, monthlyLimit: null },
];
for (const bad of cases) {
const res = await saveLimitConfig(request, bad);
expect(res.status(), `payload: ${JSON.stringify(bad)}`).toBe(400);
}
});
test('disabled limit allows unlimited sends', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-off');
await requestSendAccess(request, jwt);
await resetLimitConfig(request);
for (let i = 0; i < 3; i++) {
const { res, listener } = await sendOneMail(request, jwt, `off${i}`);
expect(res.ok()).toBe(true);
await listener!.message;
}
await deleteAddress(request, jwt);
});
test('zero limit blocks sending immediately', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-zero');
await requestSendAccess(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: 0,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
const { res } = await sendOneMail(request, jwt, 'zero', {
expectDelivery: false,
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
test('daily limit blocks once reached and returns English message', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-daily');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const allowed = 2;
const limit = baseline + allowed;
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: limit,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
for (let i = 0; i < allowed; i++) {
const { res, listener } = await sendOneMail(request, jwt, `d${i}`);
expect(res.ok(), `send #${i} should succeed`).toBe(true);
await listener!.message;
}
const { res: blocked } = await sendOneMail(request, jwt, 'd-over', {
expectDelivery: false,
});
expect(blocked.ok()).toBe(false);
const text = await blocked.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
test('monthly limit blocks once reached', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-monthly');
await requestSendAccess(request, jwt);
const baseline = await probeMonthlyBaseline(request, jwt);
const allowed = 2;
const limit = baseline + allowed;
const save = await saveLimitConfig(request, {
dailyEnabled: false,
monthlyEnabled: true,
dailyLimit: null,
monthlyLimit: limit,
});
expect(save.ok()).toBe(true);
for (let i = 0; i < allowed; i++) {
const { res, listener } = await sendOneMail(request, jwt, `m${i}`);
expect(res.ok(), `send #${i} should succeed`).toBe(true);
await listener!.message;
}
const { res: blocked } = await sendOneMail(request, jwt, 'm-over', {
expectDelivery: false,
});
expect(blocked.ok()).toBe(false);
const text = await blocked.text();
expect(text).toContain('Server monthly send quota has been reached');
await deleteAddress(request, jwt);
});
test('zh-lang header returns Chinese daily limit message', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-zh');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: baseline,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
const { res } = await sendOneMail(request, jwt, 'zh-over', {
expectDelivery: false,
lang: 'zh',
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('服务器今日发信次数已达上限');
await deleteAddress(request, jwt);
});
test('validation failures (missing subject) do not consume quota', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-noconsume');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: baseline + 1,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
// Empty subject → rejected by validation BEFORE the counter increments.
const badRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: '',
to_name: '',
to_mail: 'recipient@test.example.com',
subject: '',
content: 'no subject',
is_html: false,
},
});
expect(badRes.ok()).toBe(false);
const { res, listener } = await sendOneMail(request, jwt, 'after-bad');
expect(res.ok()).toBe(true);
await listener!.message;
await deleteAddress(request, jwt);
});
test('both daily + monthly enabled: tighter daily limit wins', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-both');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: baseline,
monthlyLimit: baseline + 10_000,
});
expect(save.ok()).toBe(true);
const { res } = await sendOneMail(request, jwt, 'both-over', {
expectDelivery: false,
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
test('minus one means unlimited at runtime', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-unlimited');
await requestSendAccess(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: -1,
monthlyLimit: -1,
});
expect(save.ok()).toBe(true);
for (let i = 0; i < 3; i++) {
const { res, listener } = await sendOneMail(request, jwt, `unl${i}`);
expect(res.ok(), `send #${i} should succeed`).toBe(true);
await listener!.message;
}
await deleteAddress(request, jwt);
});
test('/admin/send_mail_by_binding returns 400 when SEND_MAIL binding is missing', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/admin/send_mail_by_binding`, {
headers: ADMIN_HEADERS,
data: {
from: 'admin@test.example.com',
to: ['recipient@test.example.com'],
subject: 'no-binding',
text: 'body',
},
});
expect(res.status()).toBe(400);
});
test('daily and monthly counters both increment on successful send', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-both-inc');
await requestSendAccess(request, jwt);
const dailyBaseline = await probeDailyBaseline(request, jwt);
const monthlyBaseline = await probeMonthlyBaseline(request, jwt);
// Give plenty of headroom so sends succeed.
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: dailyBaseline + 10,
monthlyLimit: monthlyBaseline + 10,
});
expect(save.ok()).toBe(true);
const { res, listener } = await sendOneMail(request, jwt, 'inc');
expect(res.ok()).toBe(true);
await listener!.message;
// Re-probe to confirm both counters moved forward after the successful send.
const dailyAfter = await probeDailyBaseline(request, jwt);
expect(dailyAfter).toBeGreaterThanOrEqual(dailyBaseline + 1);
const monthlyAfter = await probeMonthlyBaseline(request, jwt);
expect(monthlyAfter).toBeGreaterThanOrEqual(monthlyBaseline + 1);
await deleteAddress(request, jwt);
});
test('admin /admin/send_mail also respects daily limit', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'limit-admin');
await requestSendAccess(request, jwt);
// Probe via a user-facing send to establish baseline.
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: baseline,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
const res = await request.post(`${WORKER_URL}/admin/send_mail`, {
headers: ADMIN_HEADERS,
data: {
from_name: '',
from_mail: address,
to_name: '',
to_mail: 'recipient@test.example.com',
subject: `admin-over-${Date.now()}`,
content: 'admin blocked body',
is_html: false,
},
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
});

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>

View File

@@ -1,12 +1,49 @@
# Configure Email Sending
::: warning Note
All three methods can be configured simultaneously. When sending emails, it will prioritize using `resend`, if `resend` is not configured, it will use `smtp`.
::: tip Recommended
Use Cloudflare `send_email` binding as the default send channel. Bind `SEND_MAIL` and finish Email Routing onboarding, then the Worker can send to any external address directly.
If a Cloudflare authenticated forwarding email address is configured, CF's internal API will be prioritized for sending emails
Workers Paid includes 3,000 messages/month, then $0.35 per 1,000 messages.
:::
## Send Channel Priority
Each `/api/send_mail` request matches channels in order; **the first hit sends**:
| Order | Condition | Channel | Deducts balance |
|-------|-----------|---------|----------------|
| 1 | `SEND_MAIL` bound **AND** recipient in `verifiedAddressList` | Cloudflare binding (compat mode) | No |
| 2 | `RESEND_TOKEN` or `RESEND_TOKEN_<DOMAIN>` set | Resend API | Yes |
| 3 | `SMTP_CONFIG` has entry for current domain | worker-mailer SMTP | Yes |
| 4 | `SEND_MAIL` bound (none of the above) | **Cloudflare binding (recommended primary)** | Yes |
| — | None of the above | Throws | — |
> [!NOTE]
> Binding send failures return an error directly.
## Using the Cloudflare `send_email` Binding (Recommended)
Only available when deploying via CLI. Add to `wrangler.toml`:
```toml
# Send emails via the Cloudflare send_email binding
send_email = [
{ name = "SEND_MAIL" },
]
```
> [!warning] Important
> The binding name must be `SEND_MAIL` — different from Cloudflare's official `SEND_EMAIL` example.
After the following steps, you can send to any external address directly:
1. Enable Email Routing on the domain in the Cloudflare Dashboard and complete onboarding
2. Add the `send_email` binding shown above to `wrangler.toml`
3. Deploy the Worker
No additional env var is required.
## Send Emails Using Resend
Register at `https://resend.com/domains` and add DNS records according to the instructions.
@@ -119,18 +156,17 @@ Users need a send balance to send emails. The balance mechanism works as follows
> [!NOTE]
> `DEFAULT_SEND_BALANCE` does **NOT** automatically grant balance to all addresses. Users must actively request send permission first for the quota to take effect.
>
> Layer 1 (`verifiedAddressList` hit) does not deduct balance, but it still counts toward send limits; layers 2/3/4 all deduct balance.
>
> Send limits apply to **all** send channels, including admin send endpoints.
>
> Daily and monthly windows are calculated in **UTC**.
>
> The current limit implementation is a **soft guard**. It is suitable for routine quota control, but it should not be treated as a strict hard-stop cost gate under database errors or high concurrency.
## Send Emails to Authenticated Forwarding Addresses on Cloudflare
Only supported for CLI deployment, add `send_email` configuration in `wrangler.toml`.
Typical use case: non-onboarded domains or Workers free-tier users.
The destination email address must be an authenticated email address on Cloudflare, which has significant limitations. If you need to send emails to other addresses, you can use `resend` or `smtp` to send emails.
```toml
# Send emails through Cloudflare
send_email = [
{ name = "SEND_MAIL" },
]
```
Admin console account configuration `Verified address list (can send emails through CF internal API)`
In this compatibility mode, mail is sent via `SEND_MAIL` binding only when the recipient is in the admin `Verified Address List`.

View File

@@ -1,13 +1,50 @@
# 配置发送邮件
::: warning 注意
三种方式可以同时配置,发送邮件时会优先使用 `resend`,如果没有配置 `resend`,则会使用 `smtp`.
::: tip 推荐方案
推荐使用 Cloudflare `send_email` binding 作为默认发信通道。绑定 `SEND_MAIL` 并完成 Email Routing onboarding 后,即可直接向任意外部地址发信。
如果配置了 Cloudflare 已认证的转发邮箱地址,会优先使用 cf 内部 API 发送邮件
Workers Paid 每月含 3,000 封,超出部分 $0.35 / 1000 封。
:::
## 使用 resend 发送邮件
## 发信通道优先级
每次 `/api/send_mail` 请求按如下顺序匹配通道,**命中即发送**
| 顺序 | 条件 | 通道 | 扣 balance |
|------|------|------|-----------|
| 1 | `SEND_MAIL` 已绑定 **且** 收件人在 `verifiedAddressList` | Cloudflare binding兼容模式 | 否 |
| 2 | `RESEND_TOKEN``RESEND_TOKEN_<DOMAIN>` 已配置 | Resend API | 是 |
| 3 | `SMTP_CONFIG` 含当前域名配置 | worker-mailer SMTP | 是 |
| 4 | `SEND_MAIL` 已绑定(以上均未命中) | **Cloudflare binding推荐主通道** | 是 |
| — | 以上均未命中 | 抛错 | — |
> [!NOTE]
> binding 发信失败会直接报错。
## 使用 Cloudflare `send_email` binding推荐
仅 CLI 部署时使用,在 `wrangler.toml` 中添加:
```toml
# 通过 Cloudflare send_email binding 发送邮件
send_email = [
{ name = "SEND_MAIL" },
]
```
> [!warning] 重要
> 绑定名必须为 `SEND_MAIL`,与 Cloudflare 官方文档示例中的 `SEND_EMAIL` 不同。
完成下列步骤后即可直接向任意外部地址发信:
1. 在 Cloudflare Dashboard 给对应域名开启 Email Routing 并完成 onboarding
2. `wrangler.toml` 添加上述 `send_email` 绑定
3. 部署 Worker
无需配置任何额外的 env var。
## 使用 Resend 发送邮件
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
@@ -119,18 +156,17 @@ wrangler secret put SMTP_CONFIG
> [!NOTE]
> `DEFAULT_SEND_BALANCE` **不会**自动给所有地址充值余额,用户必须先主动申请发信权限,额度才会生效。
>
> 第 1 层 `verifiedAddressList` 命中时不扣余额,但同样计入发信额度;第 2/3/4 层统一扣 balance。
>
> 发信额度对**全部**发信渠道生效admin 发信接口也会一起计入。
>
> 每日和每月额度按 **UTC** 时间窗口计算。
>
> 当前额度实现属于 **soft guard**,适合日常额度控制;在数据库异常或高并发场景下,它不适合作为绝对严格的成本硬闸。
## 给 Cloudflare 上已认证的转发邮箱发送邮件
仅支持 CLI 部署时使用,在 `wrangler.toml` 中添加 `send_email` 配置
适合未完成 Email Routing onboarding 的域名,或 Workers 免费版。
发送的目的邮箱地址必须是 Cloudflare 上已认证的邮箱地址,局限性较大,如果需要发送邮件给其他邮箱,可以使用 `resend` 或者 `smtp` 发送邮件
```toml
# 通过 Cloudflare 发送邮件
send_email = [
{ name = "SEND_MAIL" },
]
```
admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送邮件)`
只有收件人在 admin 后台的 `已验证地址列表` 中时,才会通过 `SEND_MAIL` binding 发信。

View File

@@ -12,7 +12,12 @@ import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin } from './send_mail'
import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
import {
getSendMailLimitConfig,
getSendMailLimitConfigToSave,
validateSendMailLimitConfig
} from '../mails_api/send_mail_limit_utils'
import db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings'
@@ -341,6 +346,7 @@ api.get('/admin/account_settings', async (c) => {
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
const addressCreationSettings = await getAddressCreationSettings(c);
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
const sendMailLimitConfig = await getSendMailLimitConfig(c);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
@@ -352,6 +358,7 @@ api.get('/admin/account_settings', async (c) => {
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
sendMailLimitConfig,
})
} catch (error) {
console.error(error);
@@ -364,7 +371,8 @@ api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
sendMailLimitConfig
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text(msgs.InvalidInputMsg, 400)
@@ -380,6 +388,12 @@ api.post('/admin/account_settings', async (c) => {
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
return c.text(msgs.InvalidInputMsg, 400)
}
const sendMailLimitConfigToSave = sendMailLimitConfig
? getSendMailLimitConfigToSave(sendMailLimitConfig)
: null;
await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
JSON.stringify(blockList)
@@ -417,6 +431,12 @@ api.post('/admin/account_settings', async (c) => {
)
}
}
if (sendMailLimitConfigToSave) {
await saveSetting(
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
JSON.stringify(sendMailLimitConfigToSave)
)
}
return c.json({
success: true
})
@@ -459,6 +479,7 @@ api.get("/admin/worker/configs", worker_config.getConfig);
// send mail by admin
api.post("/admin/send_mail", sendMailbyAdmin);
api.post("/admin/send_mail_by_binding", sendMailByBindingAdmin);
// db api
api.get('admin/db_version', db_api.getVersion);

View File

@@ -1,21 +1,89 @@
import { Context } from "hono";
import i18n from "../i18n";
import { sendMail } from "../mails_api/send_mail_api";
import { ensureSendMailLimit, increaseSendMailLimitCount } from "../mails_api/send_mail_limit_utils";
const getAdminSendMailErrorMessage = (
msgs: ReturnType<typeof i18n.getMessagesbyContext>,
error: unknown
): string => {
const message = error instanceof Error ? error.message : "";
return Object.values(msgs).includes(message)
? message
: msgs.OperationFailedMsg;
}
export const sendMailbyAdmin = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
let reqJson;
try {
reqJson = await c.req.json();
} catch (e) {
console.error("Admin send_mail invalid json", e);
return c.text(msgs.InvalidInputMsg, 400)
}
const {
from_name, from_mail,
to_mail, to_name,
subject, content, is_html
} = await c.req.json();
await sendMail(c, from_mail, {
from_name: from_name,
to_name: to_name,
to_mail: to_mail,
subject: subject,
content: content,
is_html: is_html,
}, {
isAdmin: true
})
} = reqJson;
try {
await sendMail(c, from_mail, {
from_name: from_name,
to_name: to_name,
to_mail: to_mail,
subject: subject,
content: content,
is_html: is_html,
}, {
isAdmin: true
})
} catch (e) {
console.error("Admin send_mail failed", e);
return c.text(getAdminSendMailErrorMessage(msgs, e), 400)
}
return c.json({ status: "ok" });
}
export const sendMailByBindingAdmin = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!c.env.SEND_MAIL) {
return c.text(msgs.EnableSendMailMsg, 400)
}
let reqJson;
try {
reqJson = await c.req.json();
} catch (e) {
console.error("Admin raw send_mail invalid json", e);
return c.text(msgs.InvalidInputMsg, 400)
}
const {
from, to, subject,
html, text,
cc, bcc, replyTo,
attachments, headers,
} = reqJson;
if (!from || !to || !subject || (!html && !text)) {
return c.text(msgs.InvalidInputMsg, 400)
}
try {
await ensureSendMailLimit(c);
await c.env.SEND_MAIL.send({
from,
to,
subject,
...(html ? { html } : {}),
...(text ? { text } : {}),
...(cc ? { cc } : {}),
...(bcc ? { bcc } : {}),
...(replyTo ? { replyTo } : {}),
...(attachments && attachments.length ? { attachments } : {}),
...(headers ? { headers } : {}),
});
await increaseSendMailLimitCount(c);
} catch (e) {
console.error("Admin raw send_mail failed", e);
return c.text(getAdminSendMailErrorMessage(msgs, e), 400)
}
return c.json({ status: "ok" });
}

View File

@@ -26,4 +26,6 @@ export const CONSTANTS = {
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY: "temp-mail-webhook-admin-mail-settings",
SEND_MAIL_LIMIT_COUNT_KEY_PREFIX: "send_mail_limit_count:",
SEND_MAIL_LIMIT_CONFIG_KEY: "send_mail_limit_config",
}

View File

@@ -71,7 +71,9 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "Content is empty",
AlreadyRequestedMsg: "Already requested",
EnableResendOrSmtpMsg: "Please enable resend or smtp for this domain",
EnableResendOrSmtpWithVerifiedMsg: "Please enable resend or smtp for this domain, or add recipient to verified address list",
EnableResendOrSmtpOrSendMailMsg: "Please enable resend, smtp or SEND_MAIL for this domain",
ServerSendMailDailyLimitMsg: "Server daily send quota has been reached",
ServerSendMailMonthlyLimitMsg: "Server monthly send quota has been reached",
InvalidToMailMsg: "Invalid recipient address",
// Admin related

View File

@@ -69,7 +69,9 @@ export type LocaleMessages = {
ContentEmptyMsg: string
AlreadyRequestedMsg: string
EnableResendOrSmtpMsg: string
EnableResendOrSmtpWithVerifiedMsg: string
EnableResendOrSmtpOrSendMailMsg: string
ServerSendMailDailyLimitMsg: string
ServerSendMailMonthlyLimitMsg: string
InvalidToMailMsg: string
// Admin related

View File

@@ -71,7 +71,9 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "内容不能为空",
AlreadyRequestedMsg: "已经申请过了",
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resendsmtp,或将收件人添加到已验证地址列表",
EnableResendOrSmtpOrSendMailMsg: "请先为此域名启用 resendsmtp 或 SEND_MAIL",
ServerSendMailDailyLimitMsg: "服务器今日发信次数已达上限",
ServerSendMailMonthlyLimitMsg: "服务器本月发信次数已达上限",
InvalidToMailMsg: "收件人地址无效",
// Admin related

View File

@@ -6,9 +6,12 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
import i18n from '../i18n';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue, getJsonObjectValue, getSplitStringListValue } from '../utils';
import {
getJsonSetting, getDomains, getIntValue, getBooleanValue, getJsonObjectValue, getSplitStringListValue
} from '../utils';
import { GeoData } from '../models'
import { handleListQuery, updateAddressUpdatedAt } from '../common'
import { ensureSendMailLimit, increaseSendMailLimitCount } from './send_mail_limit_utils';
export const api = new Hono<HonoCustomType>()
@@ -63,6 +66,25 @@ export const sendMailToVerifyAddress = async (
await c.env.SEND_MAIL.send(message);
}
export const sendMailByBinding = async (
c: Context<HonoCustomType>, address: string,
reqJson: {
from_name: string, to_mail: string, to_name: string,
subject: string, content: string, is_html: boolean
}
): Promise<void> => {
const {
from_name, to_mail, to_name,
subject, content, is_html
} = reqJson;
await c.env.SEND_MAIL.send({
from: from_name ? { email: address, name: from_name } : address,
to: to_name ? [`${to_name} <${to_mail}>`] : [to_mail],
subject,
...(is_html ? { html: content } : { text: content }),
});
}
const sendMailByResend = async (
c: Context<HonoCustomType>, address: string,
reqJson: {
@@ -173,6 +195,7 @@ export const sendMail = async (
if (!content) {
throw new Error(msgs.ContentEmptyMsg)
}
await ensureSendMailLimit(c);
// send to verified address list, do not update balance
const resendEnabled = c.env.RESEND_TOKEN || c.env[
@@ -202,12 +225,13 @@ export const sendMail = async (
else if (smtpConfig) {
await sendMailBySmtp(c, address, reqJson, smtpConfig);
}
else {
if (c.env.SEND_MAIL) {
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
}
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
else if (c.env.SEND_MAIL) {
await sendMailByBinding(c, address, reqJson);
}
else {
throw new Error(`${msgs.EnableResendOrSmtpOrSendMailMsg} (${mailDomain})`);
}
await increaseSendMailLimitCount(c);
// update balance
if (!sendByVerifiedAddressList && needCheckBalance) {

View File

@@ -0,0 +1,193 @@
import { Context } from "hono";
import i18n from "../i18n";
import { SendMailLimitConfig } from "../models";
import { CONSTANTS } from "../constants";
import { getJsonObjectValue, getSetting } from "../utils";
class SendMailLimitError extends Error {
constructor(message: string) {
super(message);
}
}
const parseLimitValue = (value: unknown): number | null => {
if (value === null || typeof value === "undefined") {
return null;
}
if (!Number.isInteger(value) || (value as number) < -1) {
return null;
}
return value as number;
}
const isValidLimitValue = (value: number | null): boolean => {
return value === -1 || (value !== null && value >= 0);
}
const parseSendMailLimitConfig = (value: unknown): SendMailLimitConfig | null => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const config = value as Record<string, unknown>;
if (typeof config.dailyEnabled !== "boolean" || typeof config.monthlyEnabled !== "boolean") {
return null;
}
const dailyLimit = parseLimitValue(config.dailyLimit);
const monthlyLimit = parseLimitValue(config.monthlyLimit);
const monthlyValid = config.monthlyEnabled
? isValidLimitValue(monthlyLimit)
: (config.monthlyLimit === null || typeof config.monthlyLimit === "undefined" || monthlyLimit !== null);
const dailyValid = config.dailyEnabled
? isValidLimitValue(dailyLimit)
: (config.dailyLimit === null || typeof config.dailyLimit === "undefined" || dailyLimit !== null);
if (!dailyValid || !monthlyValid) {
return null;
}
return {
dailyEnabled: config.dailyEnabled,
monthlyEnabled: config.monthlyEnabled,
dailyLimit,
monthlyLimit,
};
}
export const validateSendMailLimitConfig = (value: unknown): boolean => {
return !!parseSendMailLimitConfig(value);
}
export const getSendMailLimitConfigToSave = (
value: unknown
): SendMailLimitConfig | null => {
const sendMailLimitConfig = parseSendMailLimitConfig(value);
if (!sendMailLimitConfig) {
return null;
}
return {
dailyEnabled: sendMailLimitConfig.dailyEnabled,
monthlyEnabled: sendMailLimitConfig.monthlyEnabled,
dailyLimit: sendMailLimitConfig.dailyEnabled ? sendMailLimitConfig.dailyLimit : null,
monthlyLimit: sendMailLimitConfig.monthlyEnabled ? sendMailLimitConfig.monthlyLimit : null,
};
}
export const getSendMailLimitConfig = async (
c: Context<HonoCustomType>
): Promise<SendMailLimitConfig | null> => {
return getSendMailLimitConfigToSave(getJsonObjectValue<SendMailLimitConfig>(
await getSetting(c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY)
));
}
const getDailyCountKey = (date: Date = new Date()): string => {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}daily:${yyyy}-${mm}-${dd}`;
}
const getMonthlyCountKey = (date: Date = new Date()): string => {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}monthly:${yyyy}-${mm}`;
}
const getCount = async (
c: Context<HonoCustomType>,
key: string
): Promise<number> => {
const value = await getSetting(c, key);
if (!value) {
return 0;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
const cleanupSendMailLimitCount = async (
c: Context<HonoCustomType>,
currentDailyKey: string,
currentMonthlyKey: string
): Promise<void> => {
await c.env.DB.batch([
c.env.DB.prepare(
`DELETE FROM settings
WHERE key LIKE ?
AND key < ?`
).bind(`${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}daily:%`, currentDailyKey),
c.env.DB.prepare(
`DELETE FROM settings
WHERE key LIKE ?
AND key < ?`
).bind(`${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}monthly:%`, currentMonthlyKey),
]);
}
export const ensureSendMailLimit = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const msgs = i18n.getMessagesbyContext(c);
const config = await getSendMailLimitConfig(c);
if (!config || (!config.dailyEnabled && !config.monthlyEnabled)) {
return;
}
if (config.dailyEnabled && config.dailyLimit !== null && config.dailyLimit !== -1) {
const current = await getCount(c, getDailyCountKey());
if (current >= config.dailyLimit) {
throw new SendMailLimitError(msgs.ServerSendMailDailyLimitMsg);
}
}
if (config.monthlyEnabled && config.monthlyLimit !== null && config.monthlyLimit !== -1) {
const current = await getCount(c, getMonthlyCountKey());
if (current >= config.monthlyLimit) {
throw new SendMailLimitError(msgs.ServerSendMailMonthlyLimitMsg);
}
}
} catch (error) {
if (error instanceof SendMailLimitError) {
throw error;
}
console.warn("Failed to ensure send mail limit", error);
}
}
const increaseCount = async (
c: Context<HonoCustomType>,
key: string,
): Promise<void> => {
await c.env.DB.prepare(
`INSERT INTO settings (key, value)
VALUES (?, '1')
ON CONFLICT(key) DO UPDATE SET
value = CAST(COALESCE(value, '0') AS INTEGER) + 1,
updated_at = datetime('now')`
).bind(key).run();
}
export const increaseSendMailLimitCount = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const config = await getSendMailLimitConfig(c);
if (!config || (!config.dailyEnabled && !config.monthlyEnabled)) {
return;
}
const dailyKey = getDailyCountKey();
const monthlyKey = getMonthlyCountKey();
if (config.dailyEnabled) {
await increaseCount(c, dailyKey);
}
if (config.monthlyEnabled) {
await increaseCount(c, monthlyKey);
}
await cleanupSendMailLimitCount(c, dailyKey, monthlyKey);
} catch (error) {
if (error instanceof SendMailLimitError) {
throw error;
}
console.warn(`Failed to increment send_mail_limit_count`, error);
}
}

View File

@@ -184,6 +184,13 @@ export type EmailRuleSettings = {
emailForwardingList: SubdomainForwardAddressList[]
}
export type SendMailLimitConfig = {
dailyEnabled: boolean;
monthlyEnabled: boolean;
dailyLimit: number | null;
monthlyLimit: number | null;
}
export type RoleConfig = {
maxAddressCount?: number;
// future configs can be added here

View File

@@ -8,8 +8,8 @@ type Bindings = {
// bindings
DB: D1Database
KV: KVNamespace
RATE_LIMITER: any
SEND_MAIL: any
RATE_LIMITER: RateLimit
SEND_MAIL: SendEmail
ASSETS: Fetcher
AI: Ai