mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-06 20:32:55 +08:00
fix: auto initialize default send balance (#985)
* 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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<any> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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}` },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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.
|
||||
>
|
||||
|
||||
@@ -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"]` |
|
||||
|
||||
|
||||
@@ -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-发送邮件) |
|
||||
|
||||
|
||||
@@ -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。
|
||||
>
|
||||
|
||||
@@ -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"]` |
|
||||
|
||||
|
||||
@@ -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<HonoCustomType>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
107
worker/src/mails_api/send_balance.ts
Normal file
107
worker/src/mails_api/send_balance.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { getJsonSetting, getIntValue, getSplitStringListValue } from '../utils'
|
||||
|
||||
const ensureDefaultSendBalance = async (
|
||||
c: Context<HonoCustomType>,
|
||||
address: string
|
||||
): Promise<void> => {
|
||||
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<HonoCustomType>,
|
||||
address: string
|
||||
): Promise<number | null> => {
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender where address = ? and enabled = 1`
|
||||
).bind(address).first<number>("balance");
|
||||
return typeof balance === "number" ? balance : null;
|
||||
}
|
||||
|
||||
export const getSendBalanceState = async (
|
||||
c: Context<HonoCustomType>,
|
||||
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<HonoCustomType>,
|
||||
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' };
|
||||
}
|
||||
@@ -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<number>("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 = ?`
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user