mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-07 05:42:50 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
488
e2e/tests/api/send-mail-limit.spec.ts
Normal file
488
e2e/tests/api/send-mail-limit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 发信。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,7 +71,9 @@ const messages: LocaleMessages = {
|
||||
ContentEmptyMsg: "内容不能为空",
|
||||
AlreadyRequestedMsg: "已经申请过了",
|
||||
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
|
||||
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resend 或 smtp,或将收件人添加到已验证地址列表",
|
||||
EnableResendOrSmtpOrSendMailMsg: "请先为此域名启用 resend、smtp 或 SEND_MAIL",
|
||||
ServerSendMailDailyLimitMsg: "服务器今日发信次数已达上限",
|
||||
ServerSendMailMonthlyLimitMsg: "服务器本月发信次数已达上限",
|
||||
InvalidToMailMsg: "收件人地址无效",
|
||||
|
||||
// Admin related
|
||||
|
||||
@@ -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) {
|
||||
|
||||
193
worker/src/mails_api/send_mail_limit_utils.ts
Normal file
193
worker/src/mails_api/send_mail_limit_utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
4
worker/src/types.d.ts
vendored
4
worker/src/types.d.ts
vendored
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user