Skip to content

Commit abd5acd

Browse files
fix(plugin-email, service-sms): Send test goes to the signed-in admin when the request names no recipient (#24848)
Part of #24767 Clause-②: no This lands half A of the card, the two Send test actions. Half B, the Mail save that succeeds with its required fields empty, is left open: it is the settings save gate working as designed, and changing it is a decision about `settings-service.ts`, which this claim does not hold. The section below has the measurement. ## Half A: Send test had no recipient The Mail and SMS settings pages offer **Send test** and no recipient input. The served console posts its unsaved edits as the action body (`runSettingsAction(namespace, actionId, draft)`, objectui `apps/console/src/pages/settings/SettingsView.tsx` at the pinned `.objectui-sha` `12dc7b47c3`), so the request carries no `to`: - `mail/test` (`plugin-email`) read `ctx.body.to ?? payload.to ?? from_email`. It mailed the test to the From address (the sender, often a no-reply mailbox), or refused with `Provide a "to" address (or set from_email).` when From was empty. - `sms/test` (`service-sms`) read `ctx.body.to ?? payload.to` and refused every press from the served form. **Now:** when the request names no `to`, the test goes to the person who pressed the button. `mail/test` sends to the caller's own `sys_user.email`, and `sms/test` sends to the caller's own `sys_user.phone_number`, the column the messaging `sms` channel delivers to. A `to` in the body still wins. - The read is `findOne('sys_user', { where: { id: ctx.userId }, fields: [ONE_COLUMN] })` under the system opt-in. `where.id` is the caller's own id from the settings context, never a value the body names. So the one value it returns is the caller's own address or number, and the SMS answer masks the number. - Every refusal that remains says that nothing was sent and what to supply. A failed account read is reported as a failed read, never as "no address". - A number is on an account only where phone sign-in (the opt-in `phoneNumber` auth plugin) is enabled. On other deployments "Send test SMS" still refuses from the served form. The refusal now says why, where before it asked for a field the page does not have. **Two riders on the same handler.** The recipient default makes both reachable from the served form: - With the log-only transport (the namespace's default `None (log only)` provider), `sms/test` answered `ok: true`, "Sent test SMS … via log", and delivered nothing. It now answers `ok: false` with severity `warning` and sends nothing. `mail/test` already follows this rule for its `LogTransport`. - When the form names no delivering provider, the transport the deployment configured at boot carries the test. The success message still said "via log" in that case. It now names the deployment's configured transport. **Also removed: `ctx.body.to`.** `SettingsService.runAction` hands a handler a `SettingsContext`, which has no `body`, so no route ever reached that read. `MailSettingsSurface` now types the handler context as `{ userId?: string }`. Two queue-delivery tests passed `to` that way, and now pass it as `payload.to`, the channel the route uses. **Unchanged:** - No setting, route, spec key or manifest key is added. - None of the claim's hard stops was edited: `settings-service.ts`, `settings-service-plugin.ts`, `packages/spec/**`, objectui. ## Half B: why it is left open The measurement used a real `SettingsService` with the real `mail` manifest and a fresh in-memory store, on objectstack `87c09ebb7a` (`packages/services/service-settings/dist` built from that commit). Each console save was replayed as `setMany('mail', DIRTY_KEYS)`: | Console save (its dirty keys only) | Answer | `smtp_host` / `from_email` afterwards | |---|---|---| | `from_name`, `smtp_port`, `smtp_secure`, `smtp_user` or `queue_delivery` alone | saved | both still empty | | `provider: 'smtp'` | `SETTINGS_VALIDATION`, `smtp_host` `required` | unchanged | | `provider: 'resend'` | `SETTINGS_VALIDATION`, `api_key` `required` | unchanged | | `from_email: ''` | `SETTINGS_VALIDATION`, `from_email` `required` | unchanged | | `smtp_host: ''` | `SETTINGS_VALIDATION`, `smtp_host` `required` | unchanged | | `smtp_host: 'smtp.example.com'` | saved | `from_email` still empty | - **It is not a manifest defect.** `smtp_host` and `from_email` both declare `required: true` in `mail.manifest.ts`. - **It is the documented TOUCH gate** in `SettingsService.validatePatch`: "A specifier is checked only when the patch TOUCHES it — its own key is in the patch, or a key its `visible` expression references is". The console posts only its dirty keys. So a save that edits From name, Port, TLS, Username or the queue toggle never touches the empty required fields, and Branding's refusal is the same gate on a key the save does write. - **The choice goes to the decision frame.** Every route that changes this lands in `validatePatch` (`settings-service.ts`) or in the console. Neither is this claim's, so this PR does not close the card. The choice, with its four axes, is in the dev report on the card. ## Pins, on the real service and the real routes Each suite builds a real `SettingsService` with the real manifest and mounts its real routes through `registerSettingsRoutes`. The real plugin then registers its action at `kernel:ready`. Each case presses `POST /api/settings/NS/test` with the body the console sends. - `packages/plugins/plugin-email/src/mail-test-recipient.pin.test.ts`: - With body `{}`, the answer is 200, `ok: true`, and nodemailer is handed the admin's own address, never From. The `sys_user` read is pinned to the caller's id and the `email` column. - Control: `{ to }` wins, and no account is read. - The refusal cases each answer 400 with the ADR-0112 envelope (`success: false`, `error.code: SETTINGS_ACTION_FAILED`, `error.details` carrying `ok: false`), and nothing is sent: no address, no account row, a failed account read. An in-process call with no user also refuses. - `packages/services/service-sms/src/sms-test-recipient.pin.test.ts`: - The same shape over a Twilio draft: the `To` Twilio receives is the admin's own number, and it is masked in the answer. - Control: `{ to }` wins. - The refusal cases answer 400 `SETTINGS_ACTION_FAILED` and send nothing: no number, no account row, a failed read. The log-only provider answers severity `warning`. - `packages/services/service-sms/src/sms-plugin.test.ts`: - The log-only test no longer pins `ok: true`. It now pins `ok: false`, `warning`, and that the transport was never called. - A new case pins that a boot-configured Twilio transport is not reported "via log". - The new mail double routes `update` and `findOne` through `assertEngineUpdateDispatch` and `assertEngineFindOnePredicate`. `scripts/engine-double-contract.pinned.json` records the two rows (`check-engine-double-contract --write`: 2 added, 0 lost). ## Ablation: one leg per behaviour changed Each leg was a committed tree, mutated by `scripts/ablation-replace.mjs`: the anchor hit 1 → 0 and the blob changed. Each was restored with `git checkout HEAD -- PATH`, and the restore was proven by blob == HEAD and an empty `git diff HEAD`. The subject is imported by relative path, so no build sits between mutation and measurement. | Leg | Mutation | Result | |---|---|---| | mail default recipient | the `from_email` fallback put back | 5 of 6 mail pins red, control green | | SMS default recipient | caller id withheld from the resolver | the default-recipient pin red (and the log case, which then refuses on the recipient first) | | SMS log rule | the `LogSmsTransport` check disabled | both log pins red | | SMS transport label | `via` put back to the form's provider | the label pin red | ## Verification at `f0128702e5` - `pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2`: 35 files, 577 tests passed. - `pnpm --filter @objectstack/service-sms exec vitest run --maxWorkers=2`: 7 files, 103 tests passed. - `pnpm --filter @objectstack/plugin-email run typecheck` and the same for `@objectstack/service-sms`: exit 0, both test layers OK. The new pin files are in each `tsconfig.test.json` program, counted with `--listFiles`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 76 commands. All 76 ran with their exit captured before any pipe, and every one exited 0. `--ran` answered "76 run, 0 NOT-MEASURED (a DERIVED zero …)". - The closure was built first with `pnpm --filter '@objectstack/plugin-email^...' --filter '@objectstack/service-sms^...' --filter '@objectstack/service-settings^...' build`, after merging `origin/main` at `08d201a80e`. - Gate families CI runs are left to the PR's check-runs. ## Acceptance notes - **The console does not honour an `action_button` handler's declared shape.** The console ignores `SpecifierHandlerSchema`'s `http` `body`, `url`, `method` and `confirmText`. It posts `draft` to `/api/settings/NS/ACTION` whatever the handler declares. Every in-repo manifest's handler URL is that path, and none declares `body` or `confirmText`, so nothing visible diverges today. That is zero pull, so it is not filed. It is also why "a recipient carried by the existing payload contract" is not a route here: `body` interpolates against stored namespace values and request context, and no renderer reads it. - **The SMS fallback stub still reports a success it did not have.** `smsTestActionHandler` (the service-settings fallback) still answers `ok: true`, "Configuration looks valid … Wire @objectstack/service-sms for actual delivery". It runs only where `SmsServicePlugin` registers no action: no SMS plugin, or a host-injected transport. `sms` is always-on, so a served boot mounts the plugin, and no public door into the injected-transport composition was measured. This is a read-only inference. Carrier: none. - **The suggested "verified address" is not applied.** The address read is the caller's own. The action is gated by the namespace's write permission, whose holder can already name any `to`. The messaging email channel delivers to `sys_user.email` with no verification check either. So a verification requirement would add no protection the gate does not already give, and it would refuse the test for an admin whose own address is unverified. - **Related card:** objectstack-ai/objectui#13145 remains open. It is the opposite edge of the half B rule: clearing a saved value is refused. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3fc122 commit abd5acd

8 files changed

Lines changed: 641 additions & 20 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
'@objectstack/service-sms': patch
4+
---
5+
6+
fix(plugin-email, service-sms): Setup's "Send test email" and "Send test SMS" send to the signed-in admin when the request names no recipient (#24767)
7+
8+
Clause-②: no
9+
10+
The Mail and SMS settings pages offer a Send test button and no recipient input, so the console's request carries no `to`. "Send test email" fell back to the From address — often a no-reply mailbox nobody reads — and with From empty it refused, asking for a "to" address the page has no field for. "Send test SMS" refused every press the same way.
11+
12+
With no `to` in the request, the test now goes to the person who pressed the button: `mail/test` sends to the caller's own account email and `sms/test` to the caller's own phone number, each read from the caller's own `sys_user` row. A `to` in the request body still wins, as before. When the caller's account has no address or number, or the account cannot be read, the action refuses and says that nothing was sent. A phone number is on an account only where phone sign-in is enabled, so on other deployments "Send test SMS" still needs a `to` in the request body.
13+
14+
`sms/test` no longer reports a send through the log-only transport (the namespace's default `None (log only)` provider) as a success. It answers `ok: false` with severity `warning`, as `mail/test` already does for its log transport. When the form names no delivering provider and the transport the deployment configured at boot carries the test, the success message names that transport instead of saying the message went "via log".
15+
16+
**Unchanged.**
17+
18+
- The request and result shapes of `POST /api/settings/mail/test` and `POST /api/settings/sms/test`: no key, route or setting is added.
19+
- Which provider and credentials a test uses: the saved values overlaid with the form's unsaved edits, as before.
20+
- Saving the Mail form still judges a required field only when the save writes it, or writes a key its visibility depends on.

‎packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,8 +517,7 @@ describe('mail/test is never queued', () => {
517517

518518
const result = await h.settings.action('test')!({
519519
values: { provider: 'log', from_email: 'no-reply@example.test' },
520-
payload: {},
521-
ctx: { body: { to: 'ops@example.test' } },
520+
payload: { to: 'ops@example.test' },
522521
});
523522

524523
// The answer came from the TRANSPORT — it either sent or it did not —
@@ -542,8 +541,7 @@ describe('mail/test is never queued', () => {
542541

543542
const result = await h.settings.action('test')!({
544543
values: { provider: 'log', from_email: 'no-reply@example.test' },
545-
payload: {},
546-
ctx: { body: { to: 'ops@example.test' } },
544+
payload: { to: 'ops@example.test' },
547545
});
548546

549547
expect(result.ok).toBe(false);

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

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,77 @@ interface MailSettingsSurface {
148148
action: string,
149149
handler: (input: {
150150
values?: Record<string, unknown>;
151+
/** The request body the action was invoked with — the console posts its unsaved form edits here. */
151152
payload?: Record<string, unknown>;
152-
ctx?: { body?: Record<string, unknown> };
153+
/** The caller's settings context; `userId` is the signed-in admin who pressed the button. */
154+
ctx?: { userId?: string };
153155
}) => Promise<unknown>,
154156
): void;
155157
}
156158

159+
/** The identity object a `mail/test` press reads the caller's own address from. */
160+
const ACCOUNT_OBJECT = 'sys_user';
161+
162+
/**
163+
* Who a `mail/test` press sends to: the `to` the request names, else the
164+
* signed-in admin's own account address.
165+
*
166+
* The served form has no recipient input — a settings manifest cannot declare
167+
* a value an action posts without storing it — so the console's request
168+
* carries no `to`. This used to fall back to `from_email`, the SENDER address
169+
* (usually a no-reply mailbox nobody reads), so "Sent test email to no-reply@…"
170+
* reported a delivery the operator could not observe, and with From empty the
171+
* button refused by asking for a "to" field the page does not have. The person
172+
* who pressed the button is the one recipient who can confirm the delivery,
173+
* and a signed-in admin has an account address.
174+
*
175+
* The read is pinned to the CALLER's own row — `where.id` is the request's
176+
* own `userId`, never a value the body names — and projects `email` alone. It
177+
* runs as the system because resolving who the caller is must not depend on
178+
* the caller holding read permission on the identity table (the reason the
179+
* shared authz resolver's own `sys_user` read gives); the one value it yields
180+
* reaches the caller in the result message, and it is their own address, so
181+
* nothing the caller could not already read is disclosed.
182+
*
183+
* Every refusal says that nothing was sent and what to supply. A read that
184+
* fails is reported as a failed read, never as "you have no address".
185+
*/
186+
async function resolveMailTestRecipient(
187+
payload: unknown,
188+
userId: unknown,
189+
engine: IDataEngine,
190+
): Promise<{ to: string } | { refusal: string }> {
191+
const named = payload && typeof payload === 'object' ? (payload as { to?: unknown }).to : undefined;
192+
if (typeof named === 'string' && named.trim() !== '') return { to: named.trim() };
193+
if (typeof userId !== 'string' || userId === '') {
194+
return {
195+
refusal: 'No recipient — nothing was sent. This call names no "to" address and carries no signed-in '
196+
+ 'user whose own address the test could go to; pass "to" in the request body.',
197+
};
198+
}
199+
let row: Record<string, unknown> | null;
200+
try {
201+
row = await engine.findOne(
202+
ACCOUNT_OBJECT,
203+
{ where: { id: userId }, fields: ['email'] },
204+
{ context: { isSystem: true } },
205+
);
206+
} catch (err: any) {
207+
return {
208+
refusal: `Could not read your account's email address to send the test to (${err?.message ?? String(err)}) `
209+
+ '— nothing was sent. Pass "to" in the request body to name a recipient.',
210+
};
211+
}
212+
const email = row?.email;
213+
if (typeof email !== 'string' || email.trim() === '') {
214+
return {
215+
refusal: 'Your account has no email address, and the test is sent to the signed-in admin\'s own address '
216+
+ '— nothing was sent. Add an email address to your account, or pass "to" in the request body.',
217+
};
218+
}
219+
return { to: email.trim() };
220+
}
221+
157222
/**
158223
* Resolve a queue service that can actually carry a durable email job, or
159224
* `undefined`.
@@ -562,17 +627,20 @@ export class EmailServicePlugin implements Plugin {
562627
// provider/credentials differ from what the live `svc` is bound
563628
// to, a one-shot temporary `EmailService` is built so the
564629
// operator can validate edits before hitting "Save".
630+
//
631+
// The recipient is the request's `to`, else the signed-in admin's
632+
// own address — see `resolveMailTestRecipient` for why it is not
633+
// `from_email`.
565634
if (typeof settings.registerAction === 'function') {
566635
const svc = this.service;
567636
settings.registerAction('mail', 'test', async ({ values, payload, ctx: actionCtx }: any) => {
568637
const overrides = extractOverrides(payload);
569638
const merged: Record<string, unknown> = { ...(values ?? {}), ...overrides };
570-
const to = (actionCtx?.body?.to as string | undefined)
571-
?? (payload?.to as string | undefined)
572-
?? (merged.from_email as string | undefined);
573-
if (!to) {
574-
return { ok: false, severity: 'error', message: 'Provide a "to" address (or set from_email).' };
639+
const recipient = await resolveMailTestRecipient(payload, actionCtx?.userId, engine);
640+
if ('refusal' in recipient) {
641+
return { ok: false, severity: 'error', message: recipient.refusal };
575642
}
643+
const { to } = recipient;
576644

577645
// Build a temporary service from the merged values when
578646
// the form differs from the live svc — covers the
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Who "Send test email" sends to, driven through the SERVED door: a real
4+
// `SettingsService` holding the real `mail` manifest, its real REST routes, and
5+
// the real `mail/test` handler that `EmailServicePlugin` registers at
6+
// `kernel:ready`.
7+
//
8+
// The served form has no recipient input, so the console's request body is its
9+
// unsaved edits — `{}` on a page nobody has edited — and carries no `to`. The
10+
// handler used to fall back to `from_email`, the sender address: a "success"
11+
// mailed to a no-reply box, or, with From empty, a refusal asking for a field
12+
// the page does not have. The pins: with no `to`, the test goes to the signed-in
13+
// admin's own address, read from their own `sys_user` row; an explicit `to`
14+
// still wins; and every refusal that remains carries the ADR-0112 envelope.
15+
16+
import { describe, it, expect, vi, beforeEach } from 'vitest';
17+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
18+
import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql';
19+
import { SettingsService, mailSettingsManifest, registerSettingsRoutes } from '@objectstack/service-settings';
20+
import { EmailServicePlugin } from './email-plugin.js';
21+
22+
const nm = vi.hoisted(() => ({ createTransport: vi.fn(), sendMail: vi.fn() }));
23+
vi.mock('nodemailer', () => ({
24+
default: { createTransport: nm.createTransport },
25+
createTransport: nm.createTransport,
26+
}));
27+
28+
class MockHttp implements IHttpServer {
29+
routes = new Map<string, RouteHandler>();
30+
private add(method: string, path: string, handler: RouteHandler) {
31+
this.routes.set(`${method} ${path}`, handler);
32+
}
33+
get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; }
34+
post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; }
35+
put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; }
36+
delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; }
37+
patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; }
38+
use() { return this as any; }
39+
listen() { return Promise.resolve(); }
40+
close() { return Promise.resolve(); }
41+
getInstance() { return null; }
42+
}
43+
44+
const ADMIN_ID = 'usr_admin';
45+
const ADMIN_EMAIL = 'admin@acme.test';
46+
const FROM_EMAIL = 'no-reply@acme.test';
47+
48+
type UserRow = { id: string; email?: string | null };
49+
50+
/** The engine double: `update` and `findOne` held to ObjectQL's own dispatch contract. */
51+
function fakeEngine(users: UserRow[], opts: { userReadFails?: boolean } = {}) {
52+
return {
53+
insert: vi.fn(async (_object: string, row: any) => ({ id: row?.id })),
54+
update: vi.fn(async (_object: string, data: any, options?: any) => {
55+
assertEngineUpdateDispatch(data, options);
56+
return undefined;
57+
}),
58+
find: vi.fn(async () => []),
59+
findOne: vi.fn(async (object: string, query: any) => {
60+
assertEngineFindOnePredicate(object, query);
61+
if (object !== 'sys_user') return null;
62+
if (opts.userReadFails) throw new Error('sys_user is unavailable');
63+
return users.find((u) => u.id === query?.where?.id) ?? null;
64+
}),
65+
};
66+
}
67+
68+
function fakeCtx(services: Record<string, unknown>) {
69+
const hooks: Record<string, Array<() => Promise<void> | void>> = {};
70+
return {
71+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
72+
getService: <T,>(name: string): T => {
73+
if (!(name in services)) throw new Error(`service '${name}' not registered`);
74+
return services[name] as T;
75+
},
76+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
77+
hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); },
78+
fire: async (name: string) => { for (const fn of hooks[name] ?? []) await fn(); },
79+
};
80+
}
81+
82+
/** Boot the served `mail/test` door: real settings service + routes, the plugin's real action. */
83+
async function serve(users: UserRow[], opts: { userReadFails?: boolean } = {}) {
84+
const settings = new SettingsService({ env: {} });
85+
settings.registerManifest(mailSettingsManifest);
86+
await settings.setMany('mail', { provider: 'smtp', smtp_host: 'smtp.acme.test', from_email: FROM_EMAIL });
87+
const engine = fakeEngine(users, opts);
88+
const services: Record<string, unknown> = { manifest: { register: () => {} }, objectql: engine, settings };
89+
const ctx = fakeCtx(services);
90+
const plugin = new EmailServicePlugin();
91+
await plugin.init(ctx as never);
92+
await plugin.start(ctx as never);
93+
await ctx.fire('kernel:ready');
94+
95+
const http = new MockHttp();
96+
registerSettingsRoutes(http, settings, {
97+
contextFromRequest: () => ({
98+
enforced: true,
99+
permissions: ['manage_platform_settings'],
100+
userId: ADMIN_ID,
101+
}),
102+
});
103+
104+
/** POST /api/settings/mail/test with the body the console sends. */
105+
const pressSendTest = async (body: unknown) => {
106+
const handler = http.routes.get('POST /api/settings/:namespace/:actionId');
107+
if (!handler) throw new Error('the settings action route is not mounted');
108+
const captured: { status: number; body: any } = { status: 200, body: undefined };
109+
const res: IHttpResponse = {
110+
json: vi.fn((data: any) => { captured.body = data; }) as any,
111+
send: vi.fn() as any,
112+
status: vi.fn((code: number) => { captured.status = code; return res; }) as any,
113+
header: vi.fn(() => res) as any,
114+
};
115+
const req: IHttpRequest = {
116+
params: { namespace: 'mail', actionId: 'test' },
117+
query: {},
118+
body,
119+
headers: {},
120+
method: 'POST',
121+
path: '/api/settings/mail/test',
122+
};
123+
await handler(req, res);
124+
return captured;
125+
};
126+
return { engine, settings, pressSendTest };
127+
}
128+
129+
/** The `to` of the one message nodemailer was handed, as text. */
130+
const sentTo = () => JSON.stringify(nm.sendMail.mock.calls[0]?.[0]?.to);
131+
132+
beforeEach(() => {
133+
nm.createTransport.mockReset();
134+
nm.sendMail.mockReset();
135+
nm.createTransport.mockImplementation(() => ({ sendMail: nm.sendMail, close: vi.fn() }));
136+
nm.sendMail.mockResolvedValue({ messageId: '<wire-1@smtp>', response: '250 2.0.0 Ok' });
137+
});
138+
139+
describe('served mail/test — the recipient', () => {
140+
it('with no "to", sends to the signed-in admin\'s own address, not to From', async () => {
141+
const { engine, pressSendTest } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]);
142+
143+
const answer = await pressSendTest({});
144+
145+
expect(answer.status).toBe(200);
146+
expect(answer.body?.data).toMatchObject({ ok: true });
147+
expect(nm.sendMail).toHaveBeenCalledTimes(1);
148+
expect(sentTo()).toContain(ADMIN_EMAIL);
149+
expect(sentTo()).not.toContain(FROM_EMAIL);
150+
// The read is the caller's OWN row, projected to the address alone.
151+
expect(engine.findOne).toHaveBeenCalledWith(
152+
'sys_user',
153+
{ where: { id: ADMIN_ID }, fields: ['email'] },
154+
{ context: expect.objectContaining({ isSystem: true }) },
155+
);
156+
});
157+
158+
it('control: an explicit "to" still wins, and no account is read', async () => {
159+
const { engine, pressSendTest } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]);
160+
161+
const answer = await pressSendTest({ to: 'ops@acme.test' });
162+
163+
expect(answer.status).toBe(200);
164+
expect(nm.sendMail).toHaveBeenCalledTimes(1);
165+
expect(sentTo()).toContain('ops@acme.test');
166+
expect(sentTo()).not.toContain(ADMIN_EMAIL);
167+
expect(engine.findOne).not.toHaveBeenCalledWith('sys_user', expect.anything(), expect.anything());
168+
});
169+
170+
it.each([
171+
['an account with no email address', [{ id: ADMIN_ID, email: null }], {}],
172+
['an account row that is missing', [], {}],
173+
['a failed account read', [{ id: ADMIN_ID, email: ADMIN_EMAIL }], { userReadFails: true }],
174+
] as const)('refuses, sending nothing, for %s — ADR-0112 envelope', async (_name, users, opts) => {
175+
const { pressSendTest } = await serve([...users], opts);
176+
177+
const answer = await pressSendTest({});
178+
179+
expect(answer.status).toBe(400);
180+
expect(answer.body?.success).toBe(false);
181+
expect(answer.body?.error?.code).toBe('SETTINGS_ACTION_FAILED');
182+
expect(answer.body?.error?.details).toMatchObject({ ok: false, severity: 'error' });
183+
expect(nm.sendMail).not.toHaveBeenCalled();
184+
});
185+
186+
it('an in-process call with no "to" and no user refuses rather than mailing From', async () => {
187+
// Not a public door — the routes refuse an anonymous caller before any
188+
// action runs — but the trusted in-process path reaches the same handler.
189+
const { settings } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]);
190+
191+
const result = await settings.runAction('mail', 'test', {}, {});
192+
193+
expect(result).toMatchObject({ ok: false, severity: 'error' });
194+
expect(nm.sendMail).not.toHaveBeenCalled();
195+
});
196+
});

0 commit comments

Comments
 (0)