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:
jiaxin
2026-04-20 12:40:14 +08:00
committed by GitHub
parent d1fb1f773b
commit ebeb94ed23
17 changed files with 331 additions and 75 deletions

View File

@@ -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,

View 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' };
}

View File

@@ -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 = ?`