mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 19:48:01 +08:00
fix: align user send role and rate limits
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
|
||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||
- fix: |用户发信| 用户地址发信接口支持角色无限额度,并让同一 IP 的所有绑定地址共用发信频率限制
|
||||
|
||||
### Improvements
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<ReturnType<typeof createTestAddress>>[] = [];
|
||||
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<ReturnType<typeof createTestAddress>>[] = [];
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": "<user_JWT>",
|
||||
# "x-user-access-token": "<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
|
||||
|
||||
|
||||
@@ -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 发送邮件
|
||||
|
||||
|
||||
+15
-6
@@ -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<HonoCustomType>()
|
||||
//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')
|
||||
|
||||
Reference in New Issue
Block a user