From ebeb94ed2316e8fe357033fb566d4cc330720770 Mon Sep 17 00:00:00 2001 From: jiaxin Date: Mon, 20 Apr 2026 12:40:14 +0800 Subject: [PATCH] fix: auto initialize default send balance (#985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: auto initialize default send balance * fix: tighten send access auto init flow * refactor: centralize send balance state * fix: separate legacy repair from admin control in send balance Add an `address_sender.source` column to distinguish legacy / auto / user / admin rows. `ensureDefaultSendBalance` now only repairs rows with `source IS NULL`, so admin-disabled and user-requested rows are never overwritten. Admin POST writes tag `source = 'admin'`; new auto-init inserts tag `'auto'`; `requestSendMailAccess` inserts tag `'user'`. Bumps DB_VERSION to v0.0.8 with the usual `PRAGMA table_info` guarded ALTER, plus a standalone SQL patch under db/. Adds E2E regressions: legacy repair path, admin-disabled rows stay disabled across settings and send, send after admin deletion auto-initializes a fresh row. * fix: drop runtime legacy repair; backfill source='legacy' on migrate Pre-v0.0.8 schema cannot distinguish legacy request-send-access remnants from admin-disabled rows — both share `balance = 0, enabled = 0`. Letting ensureDefaultSendBalance repair that shape on upgrade could silently re-enable an admin-disabled row. Remove the runtime repair path entirely: - `ensureDefaultSendBalance` now uses `ON CONFLICT(address) DO NOTHING`; existing rows are never touched. - The v0.0.8 migration (and the matching SQL patch) backfills every pre-existing row with `source = 'legacy'`, making pre-migration state explicitly off-limits to runtime auto-init. - E2E: flip the legacy test to the negative direction — a `source='legacy'` zero-balance row stays untouched by settings reads and send attempts. Harden `resetSenderToLegacy` to return 404 when `meta.changes < 1`. - Update changelog and docs: legacy/admin-disabled rows must be restored manually via the admin UI. * refactor: collapse send balance auto-init to missing-row insert Per review feedback: the runtime guarantee we actually need is "create an address_sender row when one is missing, leave existing rows alone". Once `ensureDefaultSendBalance` switched to `ON CONFLICT DO NOTHING`, the `source` column, the v0.0.8 migration, and the `resetSenderToLegacy` test endpoint became dead weight — the DO NOTHING path already protects admin-disabled and admin-edited rows without any provenance metadata. - Drop `address_sender.source` and the v0.0.8 migration; revert DB_VERSION to v0.0.7. No schema change ships with this PR. - Strip the `source` field from `ensureDefaultSendBalance`, `requestSendMailAccess`, and the admin-update path. - Remove the `/admin/test/reset_sender_to_legacy` test endpoint and its E2E helper; the negative legacy-repair test it served is no longer needed because the runtime no longer touches existing rows. - E2E coverage stays focused on the three guardrails: missing-row auto-init, admin-disabled rows stay disabled, admin deletion triggers a fresh re-insert. - Tighten changelog and docs to "auto-initialize missing rows". * docs: align common-issues with missing-row-only auto-init The FAQ entries for "DEFAULT_SEND_BALANCE set but still No balance" still described the old behaviour of repairing legacy `balance = 0 && enabled = 0` rows. Rewrite both zh and en rows to match the current runtime: only addresses with no existing `address_sender` row get auto-initialised; legacy, admin-disabled, and admin-edited rows must be restored manually through the admin console. --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + e2e/fixtures/test-helpers.ts | 61 +++++++- e2e/tests/api/address-lifecycle.spec.ts | 9 +- e2e/tests/api/send-access.spec.ts | 130 ++++++++++++++++-- e2e/tests/api/send-mail.spec.ts | 13 +- frontend/src/views/index/SendMail.vue | 4 +- vitepress-docs/docs/en/guide/common-issues.md | 2 +- .../docs/en/guide/config-send-mail.md | 6 +- vitepress-docs/docs/en/guide/worker-vars.md | 2 +- vitepress-docs/docs/zh/guide/common-issues.md | 2 +- .../docs/zh/guide/config-send-mail.md | 6 +- vitepress-docs/docs/zh/guide/worker-vars.md | 2 +- worker/src/mails_api/index.ts | 7 +- worker/src/mails_api/send_balance.ts | 107 ++++++++++++++ worker/src/mails_api/send_mail_api.ts | 51 ++----- worker/wrangler.toml.template | 2 +- 17 files changed, 331 insertions(+), 75 deletions(-) create mode 100644 worker/src/mails_api/send_balance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a33031..02a5c6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ### Bug Fixes +- fix: |发送邮件| 当 `DEFAULT_SEND_BALANCE > 0` 时,首次访问发信设置或调用发信接口会为缺少 `address_sender` 记录的地址自动初始化默认额度(`ON CONFLICT DO NOTHING`),用户不再需要先手动申请发信权限;已存在的记录(包括管理员禁用或手动设置的行)一律保持原样,runtime 不会覆盖(#925 #985) - fix: |用户侧收件箱| 修复 `ENABLE_USER_DELETE_EMAIL` 关闭时用户中心仍显示删除按钮且仍可通过 `/user_api/mails/:id` 删除邮件的问题(#978) - fix: |Address| 创建邮箱时统一将配置的前缀转为小写,避免生成包含大写前缀的地址;历史数据需用户自行迁移为小写(#930) diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index a4e997eb..1122faf2 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -18,6 +18,7 @@ ### Bug Fixes +- fix: |Send Mail| Auto-initialize the default send balance for addresses that have no `address_sender` row yet when `DEFAULT_SEND_BALANCE > 0`, on the first send-settings read or send API call (`ON CONFLICT DO NOTHING`). Existing rows — including admin-disabled or admin-edited ones — are never overwritten by the runtime path, so users no longer need to manually request send permission first (#925 #985) - 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) - fix: |Address| Lowercase configured prefixes when creating addresses to avoid generating mixed-case mailbox names; existing data must be migrated to lowercase manually by the user (#930) diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 1e88c9b5..175928b2 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -183,8 +183,9 @@ export function onMailpitMessage( /** * Request send mail access for an address. - * Must be called before sending mail — creates the address_sender row - * with the DEFAULT_SEND_BALANCE configured in the worker. + * Kept for backward compatibility and manual-request flows. When + * DEFAULT_SEND_BALANCE > 0, send balance may already be auto-initialized + * before this endpoint is called. */ export async function requestSendAccess( ctx: APIRequestContext, @@ -198,6 +199,62 @@ export async function requestSendAccess( } } +/** + * Fetch the sender access row for an address from the admin API. + */ +export async function getAddressSender( + ctx: APIRequestContext, + address: string, + workerUrl: string = WORKER_URL +): Promise { + const res = await ctx.get( + `${workerUrl}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address)}`, + ); + if (!res.ok()) { + throw new Error(`Failed to get address sender: ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + if (!Array.isArray(body.results) || body.results.length < 1) { + throw new Error(`address_sender row not found for ${address}`); + } + return body.results[0]; +} + +/** + * Update a sender access row through the admin API. + */ +export async function updateAddressSender( + ctx: APIRequestContext, + opts: { + address: string; + address_id: number; + balance: number; + enabled: boolean; + }, + workerUrl: string = WORKER_URL +): Promise { + const res = await ctx.post(`${workerUrl}/admin/address_sender`, { + data: opts, + }); + if (!res.ok()) { + throw new Error(`Failed to update address sender: ${res.status()} ${await res.text()}`); + } +} + +/** + * Delete a sender access row through the admin API by its id. + */ +export async function deleteAddressSender( + ctx: APIRequestContext, + id: number, + workerUrl: string = WORKER_URL +): Promise { + const res = await ctx.delete(`${workerUrl}/admin/address_sender/${id}`); + if (!res.ok()) { + throw new Error(`Failed to delete address sender: ${res.status()} ${await res.text()}`); + } +} + /** * Delete a test address via its JWT. */ diff --git a/e2e/tests/api/address-lifecycle.spec.ts b/e2e/tests/api/address-lifecycle.spec.ts index 31bfdd8a..2adc96fc 100644 --- a/e2e/tests/api/address-lifecycle.spec.ts +++ b/e2e/tests/api/address-lifecycle.spec.ts @@ -1,18 +1,15 @@ import { test, expect } from '@playwright/test'; -import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendAccess } from '../../fixtures/test-helpers'; +import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress } from '../../fixtures/test-helpers'; test.describe('Address Lifecycle', () => { - test('create address, request send access, fetch settings, then delete', async ({ request }) => { + test('create address, auto-init send balance via settings, then delete', async ({ request }) => { // Create address const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test'); expect(address).toContain('@' + TEST_DOMAIN); expect(jwt).toBeTruthy(); expect(address_id).toBeGreaterThan(0); - // Request send access (creates address_sender row with DEFAULT_SEND_BALANCE) - await requestSendAccess(request, jwt); - - // Fetch address settings — balance should match DEFAULT_SEND_BALANCE=10 + // Fetch address settings — balance should auto-initialize from DEFAULT_SEND_BALANCE=10 const settingsRes = await request.get(`${WORKER_URL}/api/settings`, { headers: { Authorization: `Bearer ${jwt}` }, }); diff --git a/e2e/tests/api/send-access.spec.ts b/e2e/tests/api/send-access.spec.ts index 6d2fa53c..4ab656f0 100644 --- a/e2e/tests/api/send-access.spec.ts +++ b/e2e/tests/api/send-access.spec.ts @@ -1,12 +1,20 @@ import { test, expect } from '@playwright/test'; -import { WORKER_URL, createTestAddress, requestSendAccess, deleteAddress } from '../../fixtures/test-helpers'; +import { + WORKER_URL, + createTestAddress, + requestSendAccess, + deleteAddress, + deleteAddressSender, + getAddressSender, + updateAddressSender, +} from '../../fixtures/test-helpers'; test.describe('Send Access', () => { - test('request send access succeeds once, duplicate returns 400', async ({ request }) => { - const { jwt } = await createTestAddress(request, 'send-access'); + test('request send access stays idempotent when default balance is auto-initialized', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'send-access'); try { - // First request — should succeed + // First request — should succeed even if balance will also auto-init elsewhere. await requestSendAccess(request, jwt); // Verify balance is set via settings @@ -17,13 +25,119 @@ test.describe('Send Access', () => { const settings = await settingsRes.json(); expect(settings.send_balance).toBe(10); - // Duplicate request — should fail with 400 + // Duplicate request should stay safe and idempotent. const dupRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, { headers: { Authorization: `Bearer ${jwt}` }, }); - expect(dupRes.status()).toBe(400); - const dupBody = await dupRes.text(); - expect(dupBody).toContain('Already'); + expect(dupRes.ok()).toBe(true); + + const sender = await getAddressSender(request, address); + expect(sender.balance).toBe(10); + expect(sender.enabled).toBe(1); + } finally { + await deleteAddress(request, jwt); + } + }); + + test('admin-disabled rows are not overwritten by settings or send', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'sa-admin-blocked'); + + try { + await requestSendAccess(request, jwt); + + const sender = await getAddressSender(request, address); + await updateAddressSender(request, { + address, + address_id: sender.id, + balance: 0, + enabled: false, + }); + + // Reading settings must not auto-repair an admin-disabled row. + const settingsRes = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(settingsRes.ok()).toBe(true); + const settings = await settingsRes.json(); + expect(settings.send_balance).toBe(0); + + const stillDisabled = await getAddressSender(request, address); + expect(stillDisabled.balance).toBe(0); + expect(stillDisabled.enabled).toBe(0); + + // Attempting to send must also fail and must not auto-repair the row. + const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { + from_name: 'E2E', + to_name: 'E2E', + to_mail: 'recipient@test.example.com', + subject: 'should not send', + content: 'body', + is_html: false, + }, + }); + expect(sendRes.ok()).toBe(false); + + const afterSend = await getAddressSender(request, address); + expect(afterSend.balance).toBe(0); + expect(afterSend.enabled).toBe(0); + } finally { + await deleteAddress(request, jwt); + } + }); + + test('send after admin deletion auto-initializes a fresh sender row', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'send-access-deleted'); + + try { + await requestSendAccess(request, jwt); + + const sender = await getAddressSender(request, address); + await deleteAddressSender(request, sender.id); + + const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { + from_name: 'E2E', + to_name: 'E2E', + to_mail: 'recipient@test.example.com', + subject: `E2E reinit ${Date.now()}`, + content: 'body', + is_html: false, + }, + }); + expect(sendRes.ok()).toBe(true); + + // A fresh row should exist with the default balance decremented by 1. + const recreated = await getAddressSender(request, address); + expect(recreated.enabled).toBe(1); + expect(recreated.balance).toBe(9); + } finally { + await deleteAddress(request, jwt); + } + }); + + test('request send access does not falsely succeed when quota is already exhausted', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'sendexh'); + + try { + await requestSendAccess(request, jwt); + + const sender = await getAddressSender(request, address); + await updateAddressSender(request, { + address, + address_id: sender.id, + balance: 0, + enabled: true, + }); + + const retryRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(retryRes.status()).toBe(400); + const retryBody = await retryRes.text(); + expect(retryBody).toContain('Already'); } finally { await deleteAddress(request, jwt); } diff --git a/e2e/tests/api/send-mail.spec.ts b/e2e/tests/api/send-mail.spec.ts index 7b9c0985..3d5b44f7 100644 --- a/e2e/tests/api/send-mail.spec.ts +++ b/e2e/tests/api/send-mail.spec.ts @@ -3,7 +3,6 @@ import { createTestAddress, deleteAddress, deleteAllMailpitMessages, - requestSendAccess, onMailpitMessage, WORKER_URL, } from '../../fixtures/test-helpers'; @@ -15,10 +14,6 @@ test.describe('Send Mail via SMTP', () => { test('send HTML email and verify in Mailpit', async ({ request }) => { const { jwt, address } = await createTestAddress(request, 'sender-test'); - - // Must request send access before sending (creates address_sender row) - await requestSendAccess(request, jwt); - const subject = `E2E Test ${Date.now()}`; const htmlContent = '

Hello

This is an E2E test email.

'; @@ -45,6 +40,14 @@ test.describe('Send Mail via SMTP', () => { expect(mail.From.Address).toBe(address); expect(mail.To[0].Address).toBe('recipient@test.example.com'); + // Balance should auto-initialize to 10 and then decrement to 9 after sending. + const settingsRes = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(settingsRes.ok()).toBe(true); + const settings = await settingsRes.json(); + expect(settings.send_balance).toBe(9); + // Cleanup await deleteAddress(request, jwt); }); diff --git a/frontend/src/views/index/SendMail.vue b/frontend/src/views/index/SendMail.vue index 78eb3628..0d1e450c 100644 --- a/frontend/src/views/index/SendMail.vue +++ b/frontend/src/views/index/SendMail.vue @@ -33,7 +33,7 @@ const { t } = useI18n({ toMailEmpty: 'Recipient address is empty', contentEmpty: 'Content is empty', requestAccess: 'Request Access', - requestAccessTip: 'You need to request access to send mail, if have request, please contact admin.', + requestAccessTip: 'No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.', send_balance: 'Send Mail Balance Left', text: 'Text', html: 'HTML', @@ -54,7 +54,7 @@ const { t } = useI18n({ toMailEmpty: '收件人地址不能为空', contentEmpty: '内容不能为空', requestAccess: '申请权限', - requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。', + requestAccessTip: '当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。', send_balance: '剩余发送邮件额度', text: '文本', html: 'HTML', diff --git a/vitepress-docs/docs/en/guide/common-issues.md b/vitepress-docs/docs/en/guide/common-issues.md index 2ee309bf..32ed491a 100644 --- a/vitepress-docs/docs/en/guide/common-issues.md +++ b/vitepress-docs/docs/en/guide/common-issues.md @@ -34,7 +34,7 @@ | Issue | Solution | | --------------- | --------------------------------------------------------- | -| Set `DEFAULT_SEND_BALANCE` but still getting `No balance` | `DEFAULT_SEND_BALANCE` is the default quota when users **request sending permission**. Users must first click "Request Send Permission" in the frontend. Alternatively, add the address to the "No Limit Send Address List" in the admin console, or configure `NO_LIMIT_SEND_ROLE` | +| Set `DEFAULT_SEND_BALANCE` but still getting `No balance` | Refresh the settings page or try sending again first. When `DEFAULT_SEND_BALANCE > 0`, the system only auto-initializes the default quota for addresses that have **no `address_sender` row yet**; existing rows — including legacy `balance = 0 && enabled = 0` rows, admin-disabled rows, and admin-edited rows — are never modified by the runtime and must be manually restored by an admin (enable + set balance). Alternatively, add the address to the "No Limit Send Address List" in the admin console, or configure `NO_LIMIT_SEND_ROLE` | | Error: `Please enable resend or smtp for this domain` | You need to configure `RESEND_TOKEN` or `SMTP_CONFIG` first. See [Configure Email Sending](/en/guide/config-send-mail) | | `SMTP_CONFIG` configured but sending fails | Make sure the JSON key is **your own sending domain** (e.g. `your-domain.com`), not the example `awsl.uk`. See [Configure Email Sending](/en/guide/config-send-mail#send-emails-using-smtp) | diff --git a/vitepress-docs/docs/en/guide/config-send-mail.md b/vitepress-docs/docs/en/guide/config-send-mail.md index 7d437fef..68af495e 100644 --- a/vitepress-docs/docs/en/guide/config-send-mail.md +++ b/vitepress-docs/docs/en/guide/config-send-mail.md @@ -148,14 +148,14 @@ wrangler secret put SMTP_CONFIG Users need a send balance to send emails. The balance mechanism works as follows: -1. **Request Send Permission**: Users must first click the "Request Send Permission" button in the frontend -2. **Default Quota**: Upon requesting, users receive the default quota set by the `DEFAULT_SEND_BALANCE` environment variable (defaults to 0 if not set) +1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time +2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review 3. **Unlimited Sending**: The following methods can bypass balance checks: - Add the address to the "No Limit Send Address List" in the admin console - Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits > [!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. +> `DEFAULT_SEND_BALANCE` only inserts an initial quota for addresses that do not yet have an `address_sender` row (`ON CONFLICT DO NOTHING`); existing rows — including admin-disabled or admin-edited ones — are never modified by the runtime path. Restoring a previously disabled or pre-existing address must go through the admin console (enable + set balance). > > Layer 1 (`verifiedAddressList` hit) does not deduct balance, but it still counts toward send limits; layers 2/3/4 all deduct balance. > diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md index deeb66dd..1704f69e 100644 --- a/vitepress-docs/docs/en/guide/worker-vars.md +++ b/vitepress-docs/docs/en/guide/worker-vars.md @@ -37,7 +37,7 @@ | `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` | | `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` | | `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` | -| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance, will be 0 if not set | `1` | +| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `1` | | `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` | | `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` | diff --git a/vitepress-docs/docs/zh/guide/common-issues.md b/vitepress-docs/docs/zh/guide/common-issues.md index bcdacd95..ea7a36e5 100644 --- a/vitepress-docs/docs/zh/guide/common-issues.md +++ b/vitepress-docs/docs/zh/guide/common-issues.md @@ -34,7 +34,7 @@ | 问题 | 解决方案 | | --------------- | ---------------------------------------- | -| 设置了 `DEFAULT_SEND_BALANCE` 但仍提示 `No balance` | `DEFAULT_SEND_BALANCE` 是用户**申请发信权限时**的默认额度,用户需要先在前端界面点击「申请发信权限」才会生效。也可以在 admin 后台将地址加入「无限制发送地址列表」,或配置 `NO_LIMIT_SEND_ROLE` | +| 设置了 `DEFAULT_SEND_BALANCE` 但仍提示 `No balance` | 先刷新前端设置页或重试发送。当 `DEFAULT_SEND_BALANCE > 0` 时,系统只会为**尚无 `address_sender` 记录**的地址自动初始化默认额度;已有记录(包括历史 `balance = 0 且 enabled = 0` 的行、管理员禁用或手动设置的行)不会被 runtime 修改,需要管理员在后台手动启用并设置余额。也可以将地址加入「无限制发送地址列表」或配置 `NO_LIMIT_SEND_ROLE` | | 提示 `请先为此域名启用 resend 或 smtp` | 需要先配置 `RESEND_TOKEN` 或 `SMTP_CONFIG`,详见 [配置发送邮件](/zh/guide/config-send-mail) | | `SMTP_CONFIG` 配置了但发送失败 | 请确认 JSON 中的 key 是**你自己的发信域名**(如 `your-domain.com`),不要直接复制示例 key。详见 [配置发送邮件](/zh/guide/config-send-mail#使用-smtp-发送邮件) | diff --git a/vitepress-docs/docs/zh/guide/config-send-mail.md b/vitepress-docs/docs/zh/guide/config-send-mail.md index 44cfcf69..f6bef46e 100644 --- a/vitepress-docs/docs/zh/guide/config-send-mail.md +++ b/vitepress-docs/docs/zh/guide/config-send-mail.md @@ -148,14 +148,14 @@ wrangler secret put SMTP_CONFIG 用户发送邮件需要有发信余额。余额机制如下: -1. **申请发信权限**:用户需要先在前端界面点击「申请发信权限」按钮 -2. **默认额度**:申请时会获得 `DEFAULT_SEND_BALANCE` 环境变量设置的默认额度(如果未设置则为 0) +1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度 +2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录 3. **无限制发送**:以下方式可以跳过余额检查: - 在 admin 后台将地址加入「无限制发送地址列表」 - 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色 > [!NOTE] -> `DEFAULT_SEND_BALANCE` **不会**自动给所有地址充值余额,用户必须先主动申请发信权限,额度才会生效。 +> `DEFAULT_SEND_BALANCE` 仅在地址尚无 `address_sender` 记录时自动插入初始额度(`ON CONFLICT DO NOTHING`),已有记录(包括管理员禁用或手动设置的行)一律保持原样,runtime 不会修改;历史异常或被禁用的地址需由管理员在后台手动启用并设置余额。 > > 第 1 层 `verifiedAddressList` 命中时不扣余额,但同样计入发信额度;第 2/3/4 层统一扣 balance。 > diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md index cd6f4f57..6b4ede37 100644 --- a/vitepress-docs/docs/zh/guide/worker-vars.md +++ b/vitepress-docs/docs/zh/guide/worker-vars.md @@ -37,7 +37,7 @@ | `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` | | `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` | | `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` | -| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` | +| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` | | `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` | | `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` | diff --git a/worker/src/mails_api/index.ts b/worker/src/mails_api/index.ts index 6b2bcac5..b5ec3251 100644 --- a/worker/src/mails_api/index.ts +++ b/worker/src/mails_api/index.ts @@ -9,6 +9,7 @@ import auto_reply from './auto_reply' import webhook_settings from './webhook_settings'; import s3_attachment from './s3_attachment'; import address_auth from './address_auth'; +import { getSendBalanceState } from './send_balance'; export const api = new Hono() @@ -94,11 +95,7 @@ api.get('/api/settings', async (c) => { updateAddressUpdatedAt(c, address); - const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE); - const is_no_limit_send_balance = user_role && no_limit_roles.includes(user_role); - const balance = is_no_limit_send_balance ? 99999 : await c.env.DB.prepare( - `SELECT balance FROM address_sender where address = ? and enabled = 1` - ).bind(address).first("balance"); + const { balance } = await getSendBalanceState(c, address); return c.json({ address: address, send_balance: balance || 0, diff --git a/worker/src/mails_api/send_balance.ts b/worker/src/mails_api/send_balance.ts new file mode 100644 index 00000000..80a9ccb0 --- /dev/null +++ b/worker/src/mails_api/send_balance.ts @@ -0,0 +1,107 @@ +import { Context } from 'hono' + +import { CONSTANTS } from '../constants' +import { getJsonSetting, getIntValue, getSplitStringListValue } from '../utils' + +const ensureDefaultSendBalance = async ( + c: Context, + address: string +): Promise => { + if (!address) { + return; + } + const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0); + if (default_balance <= 0) { + return; + } + // Auto-initialize a sender row only when one does not exist yet. + // Existing rows — including admin-disabled ones — are never touched. + await c.env.DB.prepare( + `INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?) + ON CONFLICT(address) DO NOTHING` + ).bind(address, default_balance, 1).run(); +} + +export const getEnabledSendBalance = async ( + c: Context, + address: string +): Promise => { + const balance = await c.env.DB.prepare( + `SELECT balance FROM address_sender where address = ? and enabled = 1` + ).bind(address).first("balance"); + return typeof balance === "number" ? balance : null; +} + +export const getSendBalanceState = async ( + c: Context, + address: string, + options?: { + isAdmin?: boolean, + initializeDefaultBalance?: boolean + } +): Promise<{ + isNoLimitSender: boolean, + needCheckBalance: boolean, + balance: number | null +}> => { + const user_role = c.get("userRolePayload"); + const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE); + const is_no_limit_send_balance = typeof user_role === "string" + && no_limit_roles.includes(user_role); + const noLimitSendAddressList = is_no_limit_send_balance ? + [] : await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY) || []; + const isNoLimitSendAddress = !!noLimitSendAddressList?.includes(address); + const isNoLimitSender = is_no_limit_send_balance || isNoLimitSendAddress; + const needCheckBalance = !options?.isAdmin && !isNoLimitSender; + if (needCheckBalance && options?.initializeDefaultBalance !== false) { + await ensureDefaultSendBalance(c, address); + } + if (isNoLimitSender) { + return { + isNoLimitSender: true, + needCheckBalance: false, + balance: 99999, + }; + } + return { + isNoLimitSender: false, + needCheckBalance: needCheckBalance, + balance: await getEnabledSendBalance(c, address), + }; +} + +export const requestSendMailAccess = async ( + c: Context, + address: string +): Promise<{ + status: 'ok' | 'already_requested' | 'operation_failed' +}> => { + const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0); + if (default_balance > 0) { + await ensureDefaultSendBalance(c, address); + const { balance } = await getSendBalanceState(c, address, { + initializeDefaultBalance: false, + }); + if (balance && balance > 0) { + return { status: 'ok' }; + } + return { status: 'already_requested' }; + } + try { + const { success } = await c.env.DB.prepare( + `INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)` + ).bind( + address, default_balance, default_balance > 0 ? 1 : 0 + ).run(); + if (!success) { + return { status: 'operation_failed' }; + } + } catch (e) { + const message = (e as Error).message; + if (message && message.includes("UNIQUE")) { + return { status: 'already_requested' }; + } + return { status: 'operation_failed' }; + } + return { status: 'ok' }; +} diff --git a/worker/src/mails_api/send_mail_api.ts b/worker/src/mails_api/send_mail_api.ts index c69ef3fa..322725f5 100644 --- a/worker/src/mails_api/send_mail_api.ts +++ b/worker/src/mails_api/send_mail_api.ts @@ -6,11 +6,10 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer'; import i18n from '../i18n'; import { CONSTANTS } from '../constants' -import { - getJsonSetting, getDomains, getIntValue, getBooleanValue, getJsonObjectValue, getSplitStringListValue -} from '../utils'; +import { getJsonSetting, getDomains, getBooleanValue, getJsonObjectValue } from '../utils'; import { GeoData } from '../models' import { handleListQuery, isSendMailBindingEnabled, updateAddressUpdatedAt } from '../common' +import { getSendBalanceState, requestSendMailAccess } from './send_balance'; import { ensureSendMailLimit, increaseSendMailLimitCount } from './send_mail_limit_utils'; @@ -22,24 +21,14 @@ api.post('/api/request_send_mail_access', async (c) => { if (!address) { return c.text(msgs.AddressNotFoundMsg, 400) } - try { - const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0); - const { success } = await c.env.DB.prepare( - `INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)` - ).bind( - address, default_balance, default_balance > 0 ? 1 : 0 - ).run(); - if (!success) { - return c.text(msgs.OperationFailedMsg, 500) - } - } catch (e) { - const message = (e as Error).message; - if (message && message.includes("UNIQUE")) { - return c.text(msgs.AlreadyRequestedMsg, 400) - } - return c.text(msgs.OperationFailedMsg, 500) + const result = await requestSendMailAccess(c, address); + if (result.status === "ok") { + return c.json({ status: "ok" }) } - return c.json({ status: "ok" }) + if (result.status === "already_requested") { + return c.text(msgs.AlreadyRequestedMsg, 400) + } + return c.text(msgs.OperationFailedMsg, 500) }) export const sendMailToVerifyAddress = async ( @@ -159,21 +148,11 @@ export const sendMail = async ( if (!domains.includes(mailDomain)) { throw new Error(msgs.InvalidDomainMsg) } - const user_role = c.get("userRolePayload"); - const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE); - const is_no_limit_send_balance = user_role && no_limit_roles.includes(user_role); - // no need find noLimitSendAddressList if is_no_limit_send_balance - const noLimitSendAddressList = is_no_limit_send_balance ? - [] : await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY) || []; - const isNoLimitSendAddress = noLimitSendAddressList?.includes(address); - const needCheckBalance = !is_no_limit_send_balance && !options?.isAdmin && !isNoLimitSendAddress; - if (needCheckBalance) { - // check permission - const balance = await c.env.DB.prepare( - `SELECT balance FROM address_sender - where address = ? and enabled = 1` - ).bind(address).first("balance"); - if (!balance || balance <= 0) { + const sendBalanceState = await getSendBalanceState(c, address, { + isAdmin: options?.isAdmin, + }); + if (sendBalanceState.needCheckBalance) { + if (!sendBalanceState.balance || sendBalanceState.balance <= 0) { throw new Error(msgs.NoBalanceMsg) } } @@ -235,7 +214,7 @@ export const sendMail = async ( await increaseSendMailLimitCount(c); // update balance - if (!sendByVerifiedAddressList && needCheckBalance) { + if (!sendByVerifiedAddressList && sendBalanceState.needCheckBalance) { try { const { success } = await c.env.DB.prepare( `UPDATE address_sender SET balance = balance - 1 where address = ?` diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template index bf7fc1cc..6a8a7078 100644 --- a/worker/wrangler.toml.template +++ b/worker/wrangler.toml.template @@ -86,7 +86,7 @@ ENABLE_AUTO_REPLY = false # DISABLE_SHOW_GITHUB = true # Status monitoring page URL # STATUS_URL = "https://status.example.com" -# default send balance, if not set, it will be 0 +# default send balance, auto initialized when users open settings or send mail; if not set, it will be 0 # DEFAULT_SEND_BALANCE = 1 # the role which can send emails without limit, multiple roles can be separated by , # NO_LIMIT_SEND_ROLE = "vip"