Skip to content

Commit 0b8dbea

Browse files
fix(plugin-email): the Send test email refusals name what the admin does on the page, not a request body (#25453)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuACdPWFJTLDcTrByFCHLzqb)_ Fixes #25424 Clause-②: no Metadata-protocol: none ## What Setup → Email → **Send test email** sends to the signed-in admin's own address. When it cannot, three refusals in `resolveMailTestRecipient` (`packages/plugins/plugin-email/src/email-plugin.ts`) told the admin to pass "to" in the request body. The mail settings page has no recipient field, so the admin could not follow that advice from the page. Each refusal now keeps its opening and its "nothing was sent", and names what the admin does on the page. The wording follows the SMS twin in #25421 (`resolveSmsTestRecipient`): | Case | Opening (unchanged) | New remedy | |---|---|---| | no signed-in user (in-process call) | `No recipient — nothing was sent.` | Sign in as an admin whose account has an email address, then press Send test email again. | | account read fails | `Could not read your account's email address …` | press Send test email again once your account can be read. | | account has no email, or no row | `Your account has no email address …` | Press Send test email while signed in as an admin whose account has an email address. | A `to` in the request body still wins, as before. The override is now documented only in the function's TSDoc, for API callers. No route, response shape, status or code changes. Only the message text changes. ## Landing - The landing is the file the claim names. The refusal strings and their TSDoc are the only lines touched in `email-plugin.ts`. The live-status registration (open PR #25448) and the `provider === 'log'` branch are untouched. - Repo-wide grep for the old strings (`pass "to"`, `in the request body`, `to name a recipient`, `Add an email address to your account`, `carries no signed-in`): the only hits were the three refusals themselves. No doc, pin or other package quoted them. `content/docs` has no copy of them. `packages/*/CHANGELOG.md` is release-owned and was not searched for edits. ## Tests - New `describe('served mail/test — the recipient refusals are admin copy')` in `mail-test-recipient.pin.test.ts`. It drives the served `POST /api/settings/mail/test` door (real `SettingsService`, real routes, real handler). Each case asserts the following: - the `400` + `SETTINGS_ACTION_FAILED` envelope; - the message opening; - the remedy; - `nothing was sent` and `Send test email` are present; - there is no `/api/`, no `request body` and no `pass "to"`; - `sendMail` was never called. - The cases are: no email address, missing account row, failed account read, and the in-process call with no user. - `pnpm --filter @objectstack/plugin-email test`: Test Files 36 passed (36), Tests 675 passed (675). `pnpm --filter @objectstack/plugin-email typecheck` (tsc + check:test-typecheck): exit 0. Lock VERDICT command-exit 0 for both. - Ablation (one-shot, `scripts/ablation-replace.mjs`). The failed-read remedy was put back to the old `Pass "to" in the request body to name a recipient.` text, and the mutation landed (anchor 1 -> 0). Result: `a failed account read` went red with Tests 1 failed | 9 passed (10). The file was restored (blob == HEAD, `git diff HEAD` empty). ## Acceptance notes - None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab510f4 commit 0b8dbea

3 files changed

Lines changed: 75 additions & 5 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
---
4+
5+
fix(plugin-email): the Send test email refusals name what the admin does on the page instead of a REST recipe (#25424)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
When **Send test email** (`mail/test`) could not go to the signed-in admin's own address, its refusal said to pass "to" in the request body. The mail settings page has no recipient field, so that advice could not be followed from it. Each refusal now says that nothing was sent and what the admin can do on the page: press Send test email signed in as an admin whose account has an email address, or press it again once the account can be read. A `to` in the request still wins, as before. It is now documented only in the handler's TSDoc, for API callers.
11+
12+
No route, response shape, status or code changes: only the message text does. A caller that compared these strings verbatim must update its copy.

‎packages/plugins/plugin-email/src/email-plugin.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,15 @@ const ACCOUNT_OBJECT = 'sys_user';
186186
* reaches the caller in the result message, and it is their own address, so
187187
* nothing the caller could not already read is disclosed.
188188
*
189-
* Every refusal says that nothing was sent and what to supply. A read that
189+
* Every refusal says that nothing was sent and what to do. A read that
190190
* fails is reported as a failed read, never as "you have no address".
191+
*
192+
* Every refusal is admin copy the settings page shows: it names what the admin
193+
* does (press Send test email signed in as an admin whose account has an email
194+
* address), never a request field or an endpoint. The `to` override is for API
195+
* callers — a non-empty string `to` in the `mail/test` request body wins over
196+
* the account address and skips the account read — and is documented here,
197+
* not in the refusal.
191198
*/
192199
async function resolveMailTestRecipient(
193200
payload: unknown,
@@ -198,8 +205,9 @@ async function resolveMailTestRecipient(
198205
if (typeof named === 'string' && named.trim() !== '') return { to: named.trim() };
199206
if (typeof userId !== 'string' || userId === '') {
200207
return {
201-
refusal: 'No recipient — nothing was sent. This call names no "to" address and carries no signed-in '
202-
+ 'user whose own address the test could go to; pass "to" in the request body.',
208+
refusal: 'No recipient — nothing was sent. The test email goes to the signed-in admin\'s own email '
209+
+ 'address, and no one is signed in on this call. Sign in as an admin whose account has an email '
210+
+ 'address, then press Send test email again.',
203211
};
204212
}
205213
let row: Record<string, unknown> | null;
@@ -212,14 +220,16 @@ async function resolveMailTestRecipient(
212220
} catch (err: any) {
213221
return {
214222
refusal: `Could not read your account's email address to send the test to (${err?.message ?? String(err)}) `
215-
+ '— nothing was sent. Pass "to" in the request body to name a recipient.',
223+
+ '— nothing was sent. The test email goes to the signed-in admin\'s own address; '
224+
+ 'press Send test email again once your account can be read.',
216225
};
217226
}
218227
const email = row?.email;
219228
if (typeof email !== 'string' || email.trim() === '') {
220229
return {
221230
refusal: 'Your account has no email address, and the test is sent to the signed-in admin\'s own address '
222-
+ '— nothing was sent. Add an email address to your account, or pass "to" in the request body.',
231+
+ '— nothing was sent. Press Send test email while signed in as an admin whose account has an email '
232+
+ 'address.',
223233
};
224234
}
225235
return { to: email.trim() };

‎packages/plugins/plugin-email/src/mail-test-recipient.pin.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,51 @@ describe('served mail/test — the recipient', () => {
194194
expect(nm.sendMail).not.toHaveBeenCalled();
195195
});
196196
});
197+
198+
// Every recipient refusal is admin copy: the settings page shows it beside the
199+
// Send test email button, which has no recipient field. It names what the admin
200+
// does there, never a request field or an endpoint — the `to` override is
201+
// API-caller detail, documented in `resolveMailTestRecipient`'s TSDoc.
202+
describe('served mail/test — the recipient refusals are admin copy', () => {
203+
const adminCopy = (message: unknown) => {
204+
expect(typeof message).toBe('string');
205+
const text = message as string;
206+
expect(text).toContain('nothing was sent');
207+
expect(text).toContain('Send test email');
208+
expect(text).not.toContain('/api/');
209+
expect(text).not.toMatch(/request body/i);
210+
expect(text).not.toMatch(/pass "to"/i);
211+
};
212+
213+
it.each([
214+
['an account with no email address', [{ id: ADMIN_ID, email: null }], {},
215+
'Your account has no email address', 'signed in as an admin whose account has an email address'],
216+
['an account row that is missing', [], {},
217+
'Your account has no email address', 'signed in as an admin whose account has an email address'],
218+
['a failed account read', [{ id: ADMIN_ID, email: ADMIN_EMAIL }], { userReadFails: true },
219+
'Could not read your account\'s email address', 'once your account can be read'],
220+
] as const)('%s', async (_name, users, opts, opening, remedy) => {
221+
const { pressSendTest } = await serve([...users], opts);
222+
223+
const answer = await pressSendTest({});
224+
225+
expect(answer.status).toBe(400);
226+
expect(answer.body?.error?.code).toBe('SETTINGS_ACTION_FAILED');
227+
const message = answer.body?.error?.details?.message;
228+
expect(message).toMatch(new RegExp(`^${opening}`));
229+
expect(message).toContain(remedy);
230+
adminCopy(message);
231+
expect(nm.sendMail).not.toHaveBeenCalled();
232+
});
233+
234+
it('no signed-in user (in-process call)', async () => {
235+
const { settings } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]);
236+
237+
const result = await settings.runAction('mail', 'test', {}, {});
238+
239+
expect(result.message).toMatch(/^No recipient — nothing was sent\./);
240+
expect(result.message).toContain('Sign in as an admin whose account has an email address');
241+
adminCopy(result.message);
242+
expect(nm.sendMail).not.toHaveBeenCalled();
243+
});
244+
});

0 commit comments

Comments
 (0)