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

@@ -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.
*/

View File

@@ -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}` },
});

View File

@@ -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);
}

View File

@@ -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);
});