From 3031589a4ba7591ff34081866eb2e3f346d3ff49 Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Sun, 23 Aug 2026 21:51:56 +0800 Subject: [PATCH] fix: align user send role and rate limits --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + e2e/fixtures/wrangler.toml.e2e | 6 + e2e/tests/api/user-send-mail.spec.ts | 122 ++++++++++++++++++ .../docs/en/guide/feature/send-mail-api.md | 5 +- .../docs/zh/guide/feature/send-mail-api.md | 5 +- worker/src/worker.ts | 21 ++- 7 files changed, 153 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ff9f7..1616849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题 - fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位 +- fix: |用户发信| 用户地址发信接口支持角色无限额度,并让同一 IP 的所有绑定地址共用发信频率限制 ### Improvements diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 7f386b8..287b261 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -17,6 +17,7 @@ - fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs - fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder +- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs and share one send-rate limit across all bound addresses for the same IP ### Improvements diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 76acf6a..e1ab0ee 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -20,6 +20,7 @@ ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 +NO_LIMIT_SEND_ROLE = "case-role" ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' @@ -38,3 +39,8 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000" binding = "DB" database_name = "e2e-temp-email" database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000" + +[[ratelimits]] +name = "RATE_LIMITER" +namespace_id = "1001" +simple = { limit = 2, period = 60 } diff --git a/e2e/tests/api/user-send-mail.spec.ts b/e2e/tests/api/user-send-mail.spec.ts index 9b66858..1c86cfc 100644 --- a/e2e/tests/api/user-send-mail.spec.ts +++ b/e2e/tests/api/user-send-mail.spec.ts @@ -279,4 +279,126 @@ test.describe('User send mail API', () => { } } }); + + test('applies unlimited balance from the user role access token', async ({ request }) => { + const addresses: Awaited>[] = []; + let userId: number | undefined; + + try { + const user = await createUser(request); + userId = user.userId; + const address = await createTestAddress(request, 'user-send-role-'); + addresses.push(address); + await bindAddress(request, user.jwt, address.jwt); + + const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, { + data: { user_id: user.userId, role_text: 'case-role' }, + }); + expect(updateRoleRes.ok()).toBe(true); + + const accessRes = await request.post( + `${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(accessRes.ok()).toBe(true); + const sender = await getAddressSender(request, address.address); + await updateAddressSender(request, { + address: address.address, + address_id: sender.id, + balance: 0, + enabled: true, + }); + + const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, { + headers: { 'x-user-token': user.jwt }, + }); + expect(userSettingsRes.ok()).toBe(true); + const { access_token: accessToken } = await userSettingsRes.json(); + expect(accessToken).toBeTruthy(); + const userHeaders = { + 'x-user-token': user.jwt, + 'x-user-access-token': accessToken, + }; + + const addressSettingsRes = await request.get( + `${WORKER_URL}/user_api/address/${address.address_id}/settings`, + { headers: userHeaders }, + ); + expect(addressSettingsRes.ok()).toBe(true); + expect((await addressSettingsRes.json()).send_balance).toBe(99999); + + const sendRes = await request.post( + `${WORKER_URL}/user_api/address/${address.address_id}/send_mail`, + { + headers: userHeaders, + data: { + to_mail: 'recipient@test.example.com', + subject: `Unlimited role send ${Date.now()}`, + content: 'Sent without consuming address balance', + is_html: false, + }, + }, + ); + expect(sendRes.ok()).toBe(true); + expect((await getAddressSender(request, address.address)).balance).toBe(0); + } finally { + await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt))); + if (userId !== undefined) { + await request.delete(`${WORKER_URL}/admin/users/${userId}`); + } + } + }); + + test('shares one rate-limit bucket across bound addresses', async ({ request }) => { + const addresses: Awaited>[] = []; + let userId: number | undefined; + + try { + const user = await createUser(request); + userId = user.userId; + const first = await createTestAddress(request, 'user-rate-first-'); + const second = await createTestAddress(request, 'user-rate-second-'); + addresses.push(first, second); + await bindAddress(request, user.jwt, first.jwt); + await bindAddress(request, user.jwt, second.jwt); + + const requestAccess = async (address: typeof first) => { + const accessRes = await request.post( + `${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(accessRes.ok()).toBe(true); + }; + await requestAccess(first); + await requestAccess(second); + + const reqIp = `198.51.100.${Math.floor(Math.random() * 200) + 1}`; + const send = (address: typeof first, sequence: number) => request.post( + `${WORKER_URL}/user_api/address/${address.address_id}/send_mail`, + { + headers: { + 'x-user-token': user.jwt, + 'cf-connecting-ip': reqIp, + }, + data: { + to_mail: 'recipient@test.example.com', + subject: `Shared rate limit ${sequence} ${Date.now()}`, + content: 'Rate limit test', + is_html: false, + }, + }, + ); + + expect((await send(first, 1)).ok()).toBe(true); + expect((await send(second, 2)).ok()).toBe(true); + const limitedRes = await send(first, 3); + expect(limitedRes.status()).toBe(429); + expect(await limitedRes.text()).toContain('Rate limit exceeded'); + } finally { + await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt))); + if (userId !== undefined) { + await request.delete(`${WORKER_URL}/admin/users/${userId}`); + } + } + }); }); diff --git a/vitepress-docs/docs/en/guide/feature/send-mail-api.md b/vitepress-docs/docs/en/guide/feature/send-mail-api.md index fa2892f..273e319 100644 --- a/vitepress-docs/docs/en/guide/feature/send-mail-api.md +++ b/vitepress-docs/docs/en/guide/feature/send-mail-api.md @@ -64,6 +64,8 @@ res = requests.post( Obtain `address_id` from the paginated `GET /user_api/bind_address` response. The backend verifies that the address belongs to the current user; clients cannot choose an arbitrary sender address. +If the site grants unlimited sending to the current user's role through `NO_LIMIT_SEND_ROLE`, also send the `access_token` returned by `GET /user_api/settings`. The frontend handles this token automatically. + ```python send_body = { "from_name": "Sender Name", @@ -79,6 +81,7 @@ res = requests.post( json=send_body, headers={ "x-user-token": "", + # "x-user-access-token": "", # Required for role permissions "Content-Type": "application/json", }, ) @@ -93,7 +96,7 @@ The same user-address API group also provides: | `GET` | `/user_api/sendbox?limit=20&offset=0&address=optional-address` | List the current user's sent items, optionally filtered by a bound address | | `DELETE` | `/user_api/sendbox/:mail_id` | Delete one sent item owned by the current user | -All endpoints require a User JWT. Address-scoped endpoints verify that `address_id` is bound to the current user, while user-level sent-item endpoints only return or delete records for the user's bound addresses. +All endpoints require a User JWT. Address-scoped endpoints verify that `address_id` is bound to the current user, while user-level sent-item endpoints only return or delete records for the user's bound addresses. The user access token is only used to apply optional role permissions. ## Send Email via SMTP diff --git a/vitepress-docs/docs/zh/guide/feature/send-mail-api.md b/vitepress-docs/docs/zh/guide/feature/send-mail-api.md index 10e073a..369f57c 100644 --- a/vitepress-docs/docs/zh/guide/feature/send-mail-api.md +++ b/vitepress-docs/docs/zh/guide/feature/send-mail-api.md @@ -64,6 +64,8 @@ res = requests.post( `address_id` 可从分页接口 `GET /user_api/bind_address` 的结果中获取。后端会验证该地址属于当前用户,客户端不能自行指定发件邮箱。 +如果站点通过 `NO_LIMIT_SEND_ROLE` 为当前用户角色配置了无限发信额度,还需要传入 `GET /user_api/settings` 返回的 `access_token`。前端会自动处理该令牌。 + ```python send_body = { "from_name": "发件人名字", @@ -79,6 +81,7 @@ res = requests.post( json=send_body, headers={ "x-user-token": "<用户JWT>", + # "x-user-access-token": "<用户访问令牌>", # 使用角色权限时需要 "Content-Type": "application/json", }, ) @@ -93,7 +96,7 @@ res = requests.post( | `GET` | `/user_api/sendbox?limit=20&offset=0&address=可选地址` | 分页获取当前用户的发件箱,可按绑定地址过滤 | | `DELETE` | `/user_api/sendbox/:mail_id` | 删除当前用户的一条发件记录 | -以上接口都只接受用户 JWT。地址级接口验证 `address_id` 是否绑定到当前用户,用户级发件箱接口只返回或删除当前用户绑定地址的记录。 +以上接口都需要用户 JWT。地址级接口验证 `address_id` 是否绑定到当前用户,用户级发件箱接口只返回或删除当前用户绑定地址的记录;用户访问令牌仅用于应用可选的角色权限。 ## 通过 SMTP 发送邮件 diff --git a/worker/src/worker.ts b/worker/src/worker.ts index 8d0068b..e31718b 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -26,6 +26,11 @@ const API_PATHS = [ "/external/", ]; +const isUserSendMailRequest = (path: string): boolean => ( + path.startsWith("/user_api/address/") + && path.endsWith("/send_mail") +); + const app = new Hono() //cors app.use('/*', cors()); @@ -61,21 +66,22 @@ app.use('/*', async (c, next) => { } // rate limit for specific endpoints + const userSendMailRequest = isUserSendMailRequest(c.req.path); if ( c.req.path.startsWith("/api/new_address") || c.req.path.startsWith("/api/send_mail") || c.req.path.startsWith("/external/api/send_mail") - || ( - c.req.path.startsWith("/user_api/address/") - && c.req.path.endsWith("/send_mail") - ) + || userSendMailRequest || c.req.path.startsWith("/user_api/register") || c.req.path.startsWith("/user_api/verify_code") ) { const reqIp = c.req.raw.headers.get("cf-connecting-ip") if (reqIp && c.env.RATE_LIMITER) { + const rateLimitPath = userSendMailRequest + ? "/user_api/address/:address_id/send_mail" + : c.req.path; const { success } = await c.env.RATE_LIMITER.limit( - { key: `${c.req.path}|${reqIp}` } + { key: `${rateLimitPath}|${reqIp}` } ) if (!success) { return c.text(`IP=${reqIp} Rate limit exceeded for ${c.req.path}`, 429) @@ -206,7 +212,10 @@ app.use('/user_api/*', async (c, next) => { console.error(e); return c.text(msgs.UserTokenExpiredMsg, 401) } - if (c.req.path.startsWith("/user_api/bind_address")) { + if ( + c.req.path.startsWith("/user_api/bind_address") + || c.req.path.startsWith("/user_api/address/") + ) { await checkoutUserRolePayload(c); } if (c.req.path.startsWith('/user_api/bind_address')